diff --git a/README.md b/README.md index c39d0ab..f0d042a 100644 --- a/README.md +++ b/README.md @@ -143,31 +143,25 @@ Below is a table detailing the configuration options available in `config.json`, > ![Example](./imgs/lora_paths_example.png) > -## Load Lora Character -> Finds and applies the closest matching Lora model to a character based on name similarity. ->
-> ℹ️ See More Information -> -> - The process begins by cleaning the input character string for comparison. It then searches a JSON file for a matching character name. If a match is found, it proceeds to select the appropriate folder based on the `xl` boolean flag. -> - To find the closest Lora model, it calculates the Levenshtein distance between the character name (in full and in parts) and the filenames of available Lora models. This ensures a case-insensitive match with the best possible model. -> - If no lora is found it just returns the input model and the workflow can continue. -> -> ![Load Lora Character](./imgs/load_lora_character.png) ->
- -## Load Lora Concept +## Load Lora Folder > Dynamically applies Lora models based on similarity to a provided prompt. >
> ℹ️ See More Information > -> - This node processes a given prompt to identify and apply the most similar Lora models of the tags found in the prompt. -> - For each part of the prompt, the node calculates a distance between tags and available Lora model filenames. Models with a difference of under 5 is applied to the input `model` and `clip`. -> - Note: You need the loras you want to be selected to be in a folder called `concept` for this to work, they also need to be seperated into XL and 1.5 like stated in the `Important Note`. -> - Note: Loras need to be named very similarly to the tag, with at most 5 different characters different. The words in the lora can be seperated by spaces or underscores. +> - This node processes a given prompt to identify and apply the most similar Lora models of the tags found in the prompt. +> - For each part of the prompt, the node calculates a distance between tags and available Lora model filenames. Models with a difference of under 5 are applied to the input `model` and `clip`. +> - **Example folder input:** `*master_folder, subfolder1:3, -excludefolder, subfolder2` +> - `*master_folder` specifies all paths must include `master_folder`. +> - `subfolder1:3` indicates up to 3 models from `subfolder1` can be loaded. +> - `-excludefolder` ensures any path containing `excludefolder` is ignored. +> - `subfolder2` loads models from this subfolder without a numeric limit. +> - Note: You need the loras separated into XL and 1.5 as stated in the `Important Note`. +> - Note: Loras need to be named very similarly to the tag, with at most 5 characters different. Words in the lora filename can be separated by spaces or underscores. > -> ![Load Lora Concept](./imgs/load_lora_concept.png) +> ![Load Lora Folder](./imgs/load_lora_Folder.png) >
+ ## Get Font Size Node > Estimates the optimal font size for text to fit within an image based on Lora information. >
diff --git a/__init__.py b/__init__.py index 7177986..fca08ef 100644 --- a/__init__.py +++ b/__init__.py @@ -21,6 +21,7 @@ from .src.load_lora_concept import LoadLoraConceptNode from .src.perlin_noise import FilmGrain from .src.upscale_with_model_by import UpscaleImageBy from .src.custom_hires import CustomHires +from .src.load_lora_from_folder import LoadLoraFolderNode NODE_CLASS_MAPPINGS = { "Find SDXL Resolution": FindResolutionNode, @@ -34,14 +35,13 @@ NODE_CLASS_MAPPINGS = { "Lora Stacker": LoraStackerNode, "Load Lora XL": LoraLoraXLNode, "Load Lora 1.5": LoraLora15Node, - "Load Lora Character": LoadLoraCharacterNode, "Get Font Size": GetFontSizeNode, "Get All Styles": GetStylesNode, "Prompt Selector": PromptSelectNode, - "Load Lora Concept": LoadLoraConceptNode, "Colored Film Grain": FilmGrain, "Upscale Image With Model By": UpscaleImageBy, "Sn0w Custom Hires": CustomHires, + "Load Lora Folder": LoadLoraFolderNode, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -56,14 +56,13 @@ NODE_DISPLAY_NAME_MAPPINGS = { "Lora Stacker": "Lora Stacker", "Load Lora XL": "Load Lora XL", "Load Lora 1.5": "Load Lora 1.5", - "Load Lora Character": "Load Lora Character", "Get Font Size": "Get Font Size", "Get All Styles": "Get All Styles", "Prompt Selector": "Prompt Selector", - "Load Lora Concept": "Load Lora Concept", "Colored Film Grain": "Colored Film Grain", "Upscale Image With Model By": "Upscale Image With Model By", "Sn0w Custom Hires": "Sn0w Custom Hires", + "Load Lora Folder": "Load Lora Folder", } current_unique_id = 0 # Global variable to track the unique ID diff --git a/src/load_lora_from_folder.py b/src/load_lora_from_folder.py new file mode 100644 index 0000000..dc20f23 --- /dev/null +++ b/src/load_lora_from_folder.py @@ -0,0 +1,107 @@ +import os +import re +import folder_paths +from nodes import LoraLoader +from .sn0w import Logger, Utility + +class LoadLoraFolderNode: + logger = Logger() + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "model": ("MODEL",), + "clip": ("CLIP", ), + "prompt": ("STRING", {"default": ""}), + "folders": ("STRING", {"default": "character:1,concept"}), + "xl": ("BOOLEAN", {"default": False}), + "lora_strength": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}), + "separator": ("STRING", {"default": ", "}), + }, + } + + RETURN_TYPES = ("MODEL", "CLIP",) + RETURN_NAMES = ("MODEL", "CLIP",) + FUNCTION = "find_and_apply_lora" + CATEGORY = "sn0w" + + def clean_string(self, input_string): + cleaned_string = input_string.replace(r'\(', '(').replace(r'\)', ')') + cleaned_string = re.sub(r'[:]+(\d+(\.\d+)?)?', '', cleaned_string) + cleaned_string = re.sub(r',\s*$', '', cleaned_string.strip()) + return cleaned_string.lower() + + def normalize_folder_name(self, folder_name): + return folder_name.strip().replace('\\', '/').lower() + + def parse_folders(self, folder_string): + master_folder = None + include_folders = {} + exclude_folders = set() + folder_specs = folder_string.split(',') + + for spec in folder_specs: + parts = spec.split(':') + folder_name = self.normalize_folder_name(parts[0]) + + if folder_name.startswith('*'): + master_folder = folder_name[1:] # Remove the '*' to get the master folder name + elif folder_name.startswith('-'): + exclude_folder = folder_name[1:] # Remove the '-' to get the folder name to exclude + exclude_folders.add(exclude_folder) + else: + max_count = int(parts[1]) if len(parts) > 1 else None + include_folders[folder_name] = max_count + + return master_folder, include_folders, exclude_folders + + def find_and_apply_lora(self, model, clip, prompt, folders, xl, lora_strength, separator): + if prompt is None: + prompt = '' + prompt_parts = [self.clean_string(part) for part in prompt.split(separator)] + + # Retrieve paths and apply folder constraints + full_lora_paths = folder_paths.get_filename_list("loras") + filtered_lora_paths = folder_paths.get_filename_list("loras_xl" if xl else "loras_15") + master_folder, include_folders, exclude_folders = self.parse_folders(folders) + + # Filter by master folder if specified + if master_folder: + filtered_lora_paths = [path for path in filtered_lora_paths if master_folder in self.normalize_folder_name(path)] + + # Filter by included and excluded folders + lora_paths = [path for path in filtered_lora_paths if any(folder in self.normalize_folder_name(path) for folder in include_folders)] + lora_paths = [path for path in lora_paths if not any(exclude in self.normalize_folder_name(path) for exclude in exclude_folders)] + + lora_candidates = {} + loaded_loras = set() + + for prompt_part in prompt_parts: + for lora_path in lora_paths: + lora_filename = os.path.split(lora_path)[-1].lower() + processed_lora_filename = lora_filename.replace(".safetensors", "").replace("_", " ") + + if prompt_part in processed_lora_filename: + distance = Utility.levenshtein_distance(prompt_part, processed_lora_filename) + for full_path in full_lora_paths: + if lora_filename in full_path.lower(): + if distance <= 5: + if prompt_part not in lora_candidates: + lora_candidates[prompt_part] = [] + lora_candidates[prompt_part].append({'full_path': full_path, 'distance': distance}) + self.logger.log("Distance: " + str(distance) + " Lora: " + lora_filename + " Tag: " + prompt_part, "ALL") + break + + for prompt_part, candidates in lora_candidates.items(): + if candidates: + selected_candidate = min(candidates, key=lambda x: x['distance']) + folder_name = self.normalize_folder_name(os.path.dirname(selected_candidate['full_path'])) + if selected_candidate['full_path'] not in loaded_loras and (folder_name not in include_folders or len(loaded_loras) < include_folders[folder_name]): + self.logger.log(f"Loading Lora: {os.path.split(selected_candidate['full_path'])[-1]}", "GENERAL") + model, clip = LoraLoader().load_lora(model, clip, selected_candidate['full_path'], lora_strength, lora_strength) + loaded_loras.add(selected_candidate['full_path']) + else: + self.logger.log(f"No matching Lora found for any tags.", "GENERAL") + + return (model, clip,)