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
+[](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
-[](https://travis-ci.org/deepfakes/faceswap)
+[](https://travis-ci.org/deepfakes/faceswap) [](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