From bc0c69fe6be6958314eeea8e7539e980803d2b1a Mon Sep 17 00:00:00 2001 From: Vincent at Jupiter Date: Fri, 26 Dec 2025 23:57:45 +0800 Subject: [PATCH] added iteration feats. modules resturctured. --- js/v1/Printf.js | 4 +- node_mappings.py | 112 +++-------- node_registry.py | 9 + py/nodes_matplot.py | 1 - py/nodes_opencv.py | 16 ++ py/nodes_photoedit.py | 194 +++++++++++++++++++ py/nodes_utils.py | 431 +++++++++++++++++++++++++++++------------- pyproject.toml | 2 +- 8 files changed, 542 insertions(+), 227 deletions(-) create mode 100644 node_registry.py delete mode 100644 py/nodes_matplot.py create mode 100644 py/nodes_photoedit.py diff --git a/js/v1/Printf.js b/js/v1/Printf.js index 704f18d..fb2b88e 100644 --- a/js/v1/Printf.js +++ b/js/v1/Printf.js @@ -1,7 +1,7 @@ // below implementation is from https://github.com/pythongosssss/ComfyUI-Custom-Scripts -import { app } from "../../../scripts/app.js"; -import { ComfyWidgets } from "../../../scripts/widgets.js"; +import { app } from "/scripts/app.js"; +import { ComfyWidgets } from "/scripts/widgets.js"; // Displays input text on a node diff --git a/node_mappings.py b/node_mappings.py index e6910db..416f1c4 100644 --- a/node_mappings.py +++ b/node_mappings.py @@ -1,5 +1,6 @@ from .py.log import log import importlib +from .node_registry import REGISTERED_NODES # import importlib.util # for auto import NODE_CLASS_MAPPINGS = {} @@ -15,96 +16,29 @@ def AddMapping(node_class): else: NODE_DISPLAY_NAME_MAPPINGS["AG "+node_class.NAME] = node_class.NAME+" 🐦" -#--------------------------------------------------------------------------------# -# Load Image Nodes -#--------------------------------------------------------------------------------# +node_modules = [ + ".py.nodes_photoedit", + ".py.nodes_utils", + ".py.nodes_opencv", + ".py.nodes_xfeat", + # New categories can be added here +] + +# import modules try: - nodes_utils = importlib.import_module(".py.nodes_utils", package=__package__) - for name in ( - "JsonSave", - "JsonLoad", - "NparraySave", - "NparrayLoad", - "SaveNparrayImage", - "PreviewNparrayImage", - - "Printf", - "SliceList", - "SliceSublist", - - "ImageResizeTo", - "Image2BW", - ): - if hasattr(nodes_utils, name): - AddMapping(getattr(nodes_utils, name)) - + for module_path in node_modules: + importlib.import_module(module_path, package=__package__) except ImportError as e: - log("Failed to load util nodes", msg_color="BRIGHT_GREEN") + log(f"Failed to load nodes from {module_path}", msg_color="BRIGHT_GREEN") log(e, msg_color="BRIGHT_GREEN") -#--------------------------------------------------------------------------------# -# Load OpenCV Nodes -#--------------------------------------------------------------------------------# -try: - nodes_opencv = importlib.import_module(".py.nodes_opencv", package=__package__) - for name in ( - "Image2Nparray", - "Nparray2Image", - - "Bitwise_not", - "Threshold", - "GaussianBlur", - "MedianBlur", - "Blur", - - "Sobel", - "Canny", - "HoughLines", - "HoughLinesP", - "FindContours", - "DrawContours", - "Circles", - - "ConnectedComponentsWithStats", - ): - if hasattr(nodes_opencv, name): - AddMapping(getattr(nodes_opencv, name)) - -except ImportError as e: - log("Failed to load opencv nodes", msg_color="BRIGHT_GREEN") - log(e, msg_color="BRIGHT_GREEN") - -#--------------------------------------------------------------------------------# -# Load XFeat Nodes -#--------------------------------------------------------------------------------# -try: - # use a dynamic (string) import to avoid static analysis warnings when the - # premium module is not present in free distributions - nodes_xfeat = importlib.import_module(".py.nodes_xfeat", package=__package__) - for name in ( - "ColormapHotGray", - - "HoughLinesAccumulator", - "HoughLinesPlotter", - "KLargestPoints", - "PointToLineParams", - "IntersectImageWithMask", - "MorphologyByRegions", - "MorphologyByRegionsWithStats", - - "DrawQuiver", - "LabelsToContours", - "ContoursToLabels", - "LabelsPreview", - "CreateGVF", - "GVFSnake", - - ): - if hasattr(nodes_xfeat, name): - AddMapping(getattr(nodes_xfeat, name)) - - # from .py.nodes_xfeat import MorphCloseByRegions - # AddMapping(MorphCloseByRegions) - -except ImportError as e: - log("Premium nodes ignored", msg_color="BRIGHT_GREEN") \ No newline at end of file +# register all nodes +for node_class in REGISTERED_NODES: + try: + AddMapping(node_class) + except Exception as e: + if hasattr(node_class, "NAME"): + log(f"Failed to register nodes {node_class.NAME}", msg_color="BRIGHT_GREEN") + else: + log("Failed to register nodes", msg_color="BRIGHT_GREEN") + log(e, msg_color="BRIGHT_GREEN") \ No newline at end of file diff --git a/node_registry.py b/node_registry.py new file mode 100644 index 0000000..2f6a692 --- /dev/null +++ b/node_registry.py @@ -0,0 +1,9 @@ +#--------------------------------------------------------------------------------# +# Global list to hold registered nodes +#--------------------------------------------------------------------------------# +REGISTERED_NODES = [] + +def comfy_node(cls): + """ComfyUI decorator""" + REGISTERED_NODES.append(cls) + return cls \ No newline at end of file diff --git a/py/nodes_matplot.py b/py/nodes_matplot.py deleted file mode 100644 index f2b4b2e..0000000 --- a/py/nodes_matplot.py +++ /dev/null @@ -1 +0,0 @@ -CURRENT_CATEGORY = "🐦‍🔥 ArchiGraph/📊 Matplot" # organized by py file \ No newline at end of file diff --git a/py/nodes_opencv.py b/py/nodes_opencv.py index b13fc8e..911f3dc 100644 --- a/py/nodes_opencv.py +++ b/py/nodes_opencv.py @@ -7,10 +7,12 @@ import comfy.model_management as model_management from .anyType import ANY from .enums import ColorEnum, ThreshEnum, BorderEnum, ContourModeEnum, \ ContourMethodEnum, DepthEnum, ColormapEnum +from ..node_registry import comfy_node #---------------------------------------------------------------------------------------------------------------------# # Helpers #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class Image2Nparray: @classmethod def INPUT_TYPES(cls): @@ -43,6 +45,7 @@ class Image2Nparray: return (ret,) #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class Nparray2Image: @classmethod def INPUT_TYPES(cls): @@ -74,6 +77,7 @@ class Nparray2Image: #---------------------------------------------------------------------------------------------------------------------# # OpenCV Image Processing Nodes #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class Bitwise_not: @classmethod def INPUT_TYPES(cls): @@ -100,6 +104,7 @@ class Bitwise_not: return (result,) #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class Threshold: @classmethod def INPUT_TYPES(cls): @@ -144,6 +149,7 @@ class Threshold: return (result,) #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class GaussianBlur: @classmethod def INPUT_TYPES(cls): @@ -200,6 +206,7 @@ class GaussianBlur: return (result,) #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class MedianBlur: @classmethod def INPUT_TYPES(cls): @@ -233,6 +240,7 @@ class MedianBlur: return (result,) #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class Blur: @classmethod def INPUT_TYPES(cls): @@ -281,6 +289,7 @@ class Blur: return (result,) #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class Sobel: @classmethod def INPUT_TYPES(cls): @@ -345,6 +354,7 @@ class Sobel: return (result,) #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class Canny: @classmethod def INPUT_TYPES(cls): @@ -412,6 +422,7 @@ class Canny: return (result,) #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class HoughLines: @classmethod def INPUT_TYPES(cls): @@ -475,6 +486,7 @@ class HoughLines: return (list_lines,) #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class HoughLinesP: @classmethod def INPUT_TYPES(cls): @@ -535,6 +547,7 @@ class HoughLinesP: return (list_lines,) #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class FindContours: @classmethod def INPUT_TYPES(cls): @@ -579,6 +592,7 @@ class FindContours: return (contours, hierarchy, ) #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class DrawContours: @classmethod def INPUT_TYPES(cls): @@ -635,6 +649,7 @@ class DrawContours: return (result[np.newaxis, :],) #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class Circles: @classmethod def INPUT_TYPES(cls): @@ -692,6 +707,7 @@ class Circles: return (result,) #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class ConnectedComponentsWithStats: @classmethod def INPUT_TYPES(cls): diff --git a/py/nodes_photoedit.py b/py/nodes_photoedit.py new file mode 100644 index 0000000..f69316c --- /dev/null +++ b/py/nodes_photoedit.py @@ -0,0 +1,194 @@ +CURRENT_CATEGORY = "🐦‍🔥 ArchiGraph/📷 PhotoEdit" # organized by py file + +import torch +from comfy.utils import common_upscale +from ..node_registry import comfy_node + +#---------------------------------------------------------------------------------------------------------------------# +@comfy_node +class ImageResizeTo: + upscale_methods = ["lanczos", "nearest-exact", "bilinear", "area", "bicubic"] + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + """ + Return a dictionary which contains config for all input fields. + Some types (string): "MODEL", "VAE", "CLIP", "CONDITIONING", "LATENT", "IMAGE", "INT", "STRING", "FLOAT". + Input types "INT", "STRING" or "FLOAT" are special values for fields on the node. + The type can be a list for selection. + + Returns: `dict`: + - Key input_fields_group (`string`): Can be either required, hidden or optional. A node class must have property `required` + - Value input_fields (`dict`): Contains input fields config: + * Key field_name (`string`): Name of a entry-point method's argument + * Value field_config (`tuple`): + + First value is a string indicate the type of field or a list for selection. + + Second value is a config for type "INT", "STRING" or "FLOAT". + """ + return { + "required": { + "image": ("IMAGE",{ + "lazy": False # Will only be evaluated if check_lazy_status requires it + }), + "target_size": (["720P", "1080P", "2K", "4K", "custom"], { + "default": "720P", # Default value + "tooltip": "Preset target sizes. Select 'custom' to specify edge size manually.", + }), + "bbox_edge1": ("INT", { + "default": 1024, + "min": 16, #Minimum value + "max": 8192, #Maximum value + "step": 2, #Slider's step + "display": "number", # Cosmetic only: display as "number" or "slider" + "tooltip": "Only valid for custom target type. Edge size for the boundingbox. Orientation will be calculated automatically.", + }), + "bbox_edge2": ("INT", { + "default": 1024, + "min": 16, #Minimum value + "max": 8192, #Maximum value + "step": 2, #Slider's step + "display": "number", # Cosmetic only: display as "number" or "slider" + "tooltip": "Only valid for custom target type. Edge size for the boundingbox. Orientation will be calculated automatically.", + }), + "upscale_method": (cls.upscale_methods,), + "onlyEnlarge": ("BOOLEAN", { + "default": True, + "tooltip": "If true, only enlarge images. Large images will be kept at original size.", + }), + }, + } + + RETURN_TYPES = ("IMAGE", "INT", "INT", "STRING") + RETURN_NAMES = ("IMAGE", "prev_width", "prev_height", "log") + FUNCTION = "resize" + NAME = "Image Resize To" + CATEGORY = CURRENT_CATEGORY + DESCRIPTION = "Resizes an image by specify one edge size. Proportion is kept by default." + + def resize(self, image: torch.Tensor, target_size: str, bbox_edge1: int, bbox_edge2: int, upscale_method: str, onlyEnlarge: bool): + + # get original image size + B, H, W, C = image.shape + prev_width = W + prev_height = H + + image = image.movedim(-1, 1) + + ratio = max(W, H) * 1.0 / min(W, H) + short_edge = 0 + long_edge = 0 + + if target_size == "custom": + bbox_short = min(bbox_edge1, bbox_edge2) + bbox_long = max(bbox_edge1, bbox_edge2) + new_ratio = bbox_long * 1.0 / bbox_short + if ratio > new_ratio: + short_edge = bbox_short + long_edge = int(bbox_short * ratio) + else: + long_edge = bbox_long + short_edge = int(bbox_long / ratio) + else: + new_ratio = 16.0 / 9.0 + if ratio > new_ratio: + short_edge = 720 + long_edge = int(720 * ratio) + else: + long_edge = 1280 + short_edge = int(1280 / ratio) + + if target_size == "720P": + pass + elif target_size == "1080P": + short_edge *= 1.5 + long_edge *= 1.5 + elif target_size == "2K": + short_edge *= 2 + long_edge *= 2 + elif target_size == "4K": + short_edge *= 3 + long_edge *= 3 + + if W >= H: + new_width = long_edge + new_height = short_edge + else: + new_width = short_edge + new_height = long_edge + + if onlyEnlarge and new_width <= W: + log = f"Original image is larger, not resized." + else: + image = common_upscale(image, new_width, new_height, upscale_method, "disabled") + log = f"Resized from W{prev_width}xH{prev_height} to W{new_width}xH{new_height}." + + image = image.movedim(1, -1) + return (image, prev_width, prev_height, log,) + +#---------------------------------------------------------------------------------------------------------------------# +@comfy_node +class Image2BW: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",{ + "lazy": False + }), + "reds":("INT", {"default": 40, "min": -200, "max": 300, "display": "slider"}), + "yellows":("INT", {"default": 60, "min": -200, "max": 300, "display": "slider"}), + "greens":("INT", {"default": 40, "min": -200, "max": 300, "display": "slider"}), + "cyans":("INT", {"default": 60, "min": -200, "max": 300, "display": "slider"}), + "blues":("INT", {"default": 20, "min": -200, "max": 300, "display": "slider"}), + "magentas":("INT", {"default": 80, "min": -200, "max": 300, "display": "slider"}), + }, + } + + RETURN_TYPES = ("IMAGE", ) + RETURN_NAMES = ("IMAGE", ) + FUNCTION = "bw" + NAME = "Image to BW" + CATEGORY = CURRENT_CATEGORY + DESCRIPTION = "Converts an image to black & white like the adjustments in Photoshop." + + def bw(self, image, reds, yellows, greens, cyans, blues, magentas): + + datatype = torch.float32 + # get original image size + B, H, W, C = image.shape + + color_ratios = torch.tensor([reds, greens, blues, cyans, magentas, yellows], dtype=datatype) + color_ratios = (color_ratios + 200) / 500 + + sorted_vals, sorted_idx = torch.sort(image, dim=3, descending=True) # BxHxWxC + + # Get the color ratio value of the maximum channel + max_idx = sorted_idx[:, :, :, 0].unsqueeze(-1) # BxHxWx1 + ratios_reshaped = color_ratios.unsqueeze(0).unsqueeze(0).unsqueeze(0).expand(B,H,W,-1) # BxHxWx6 + ratio_max = torch.gather(ratios_reshaped, 3, max_idx) # BxHxWx1 + + combo_idx = (sorted_idx[:, :, :, -1] + 3).unsqueeze(-1) # BxHxWx1 + ratio_combo = torch.gather(ratios_reshaped, 3, combo_idx) # BxHxWx1 + + # duplicate sorted_vals to a new tensor + sorted_vals_2 = sorted_vals.clone() + sorted_vals_2[:, :, :, 0] = 0 + + # move the first value to the last in sorted_vals_2 + sorted_vals_2 = torch.cat((sorted_vals_2[:, :, :, 1:], sorted_vals_2[:, :, :, 0].unsqueeze(-1)), dim=-1) # BxHxWxC + + diff = sorted_vals - sorted_vals_2 # BxHxWxC + ratio = torch.cat((ratio_max, ratio_combo, torch.ones_like(ratio_max, dtype=datatype)), dim=-1) # BxHxWx3 + + # calculate the sum product of diff and ratio + final = torch.sum(diff * ratio,dim=-1) # BxHxWx1 + + return (final, ) + +#---------------------------------------------------------------------------------------------------------------------# diff --git a/py/nodes_utils.py b/py/nodes_utils.py index c293fff..f616aa7 100644 --- a/py/nodes_utils.py +++ b/py/nodes_utils.py @@ -1,21 +1,36 @@ CURRENT_CATEGORY = "🐦‍🔥 ArchiGraph/🛠️ Utils" # organized by py file import os -import random +import random, uuid, hashlib, time import torch -from comfy.utils import common_upscale import numpy as np +from PIL import Image from .anyType import ANY from .nodes_opencv import Nparray2Image +from ..node_registry import comfy_node # import comfyui core node +from server import PromptServer # import comfyui server module to manupulate prompt queue from nodes import SaveImage import folder_paths NPARRAY_DIR = os.path.join(folder_paths.get_output_directory(), "nparrays") JSON_DIR = os.path.join(folder_paths.get_output_directory(), "jsons") #---------------------------------------------------------------------------------------------------------------------# +# Helpers +#---------------------------------------------------------------------------------------------------------------------# +def resolve_path(path: str) -> str: + # 1. 展开环境变量(如 %USERPROFILE%) + path = os.path.expandvars(path) + # 2. 展开用户目录(如 ~ 表示用户主目录) + path = os.path.expanduser(path) + # 3. 转换为绝对路径并规范化 + path = os.path.abspath(path) + return path + +#---------------------------------------------------------------------------------------------------------------------# +@comfy_node class JsonSave: @classmethod def INPUT_TYPES(cls): @@ -59,6 +74,7 @@ class JsonSave: return (file_path,) #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class JsonLoad: @classmethod def INPUT_TYPES(cls): @@ -108,6 +124,7 @@ class JsonLoad: return (data,) #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class NparraySave: @classmethod def INPUT_TYPES(cls): @@ -140,6 +157,7 @@ class NparraySave: return (file_path,) #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class NparrayLoad: @classmethod def INPUT_TYPES(cls): @@ -186,6 +204,7 @@ class NparrayLoad: return (nparray,) #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class SaveNparrayImage(SaveImage): @classmethod def INPUT_TYPES(cls): @@ -211,6 +230,7 @@ class SaveNparrayImage(SaveImage): return super().save_images(images, filename_prefix, prompt, extra_pnginfo) #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class PreviewNparrayImage(SaveNparrayImage): def __init__(self): self.output_dir = folder_paths.get_temp_directory() @@ -229,9 +249,10 @@ class PreviewNparrayImage(SaveNparrayImage): DESCRIPTION = "Previews the input nparray images by saving them to a temporary directory." #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class Printf: ''' - Later can be used to unravel list and show specified elements only. + TODO: Can be used to unravel list and show specified elements only. ''' @classmethod def INPUT_TYPES(cls): @@ -249,7 +270,7 @@ class Printf: } OUTPUT_NODE = True - # INPUT_IS_LIST = True # if the input is a list, workflow will pass each element separately + INPUT_IS_LIST = False # if the input is a list, workflow will pass each element separately OUTPUT_IS_LIST = (True,) RETURN_TYPES = ("STRING",) RETURN_NAMES = ("string",) @@ -280,27 +301,10 @@ class Printf: else: text.append(f"Type: {type(any)}, Value:\n{any}") - # save to workflow pnginfo - if unique_id is not None and extra_pnginfo is not None: - if not isinstance(extra_pnginfo, list): - print("Error: extra_pnginfo is not a list") - elif ( - not isinstance(extra_pnginfo[0], dict) - or "workflow" not in extra_pnginfo[0] - ): - print("Error: extra_pnginfo[0] is not a dict or missing 'workflow' key") - else: - workflow = extra_pnginfo[0]["workflow"] - node = next( - (x for x in workflow["nodes"] if str(x["id"]) == str(unique_id[0])), - None, - ) - if node: - node["widgets_values"] = [text] - return {"ui": {"text": text}, "result": (text,)} #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class SliceList: @classmethod def INPUT_TYPES(cls): @@ -338,6 +342,7 @@ class SliceList: return (sliced,) #---------------------------------------------------------------------------------------------------------------------# +@comfy_node class SliceSublist: @classmethod def INPUT_TYPES(cls): @@ -381,148 +386,306 @@ class SliceSublist: return (sliced,) #---------------------------------------------------------------------------------------------------------------------# -class ImageResizeTo: - upscale_methods = ["lanczos", "nearest-exact", "bilinear", "area", "bicubic"] - +@comfy_node +class SaveImageToPath: def __init__(self): - pass + self.compress_level = 4 @classmethod def INPUT_TYPES(cls): - """ - Return a dictionary which contains config for all input fields. - Some types (string): "MODEL", "VAE", "CLIP", "CONDITIONING", "LATENT", "IMAGE", "INT", "STRING", "FLOAT". - Input types "INT", "STRING" or "FLOAT" are special values for fields on the node. - The type can be a list for selection. - - Returns: `dict`: - - Key input_fields_group (`string`): Can be either required, hidden or optional. A node class must have property `required` - - Value input_fields (`dict`): Contains input fields config: - * Key field_name (`string`): Name of a entry-point method's argument - * Value field_config (`tuple`): - + First value is a string indicate the type of field or a list for selection. - + Second value is a config for type "INT", "STRING" or "FLOAT". - """ return { "required": { - "image": ("IMAGE",{ - "lazy": False # Will only be evaluated if check_lazy_status requires it + "images": ("IMAGE", {"tooltip": "The images to save."}), + "target_folder": ("STRING", { + "default": "%USERPROFILE%", + "tooltip": "Target size for resizing." }), - "edge_size": ("INT", { - "default": 1024, - "min": 16, #Minimum value - "max": 8192, #Maximum value - "step": 2, #Slider's step - "display": "number", # Cosmetic only: display as "number" or "slider" + "filename": ("STRING", { + "default": "output", + "tooltip": "Filename without extension (extension .png will be added automatically).", }), - "edge_type": ("BOOLEAN", { - "default": True, # Default value - "label_on": "By Short Edge", # Label for the first option - "label_off": "By Long Edge", # Label for the second option - }), - # "is_by_short": ("BOOL", { - - # }), - "upscale_method": (cls.upscale_methods,), }, } - - RETURN_TYPES = ("IMAGE", "INT", "INT", "STRING") - RETURN_NAMES = ("IMAGE", "prev_width", "prev_height", "log") - FUNCTION = "resize" - NAME = "Image Resize To" + + OUTPUT_NODE = True + RETURN_TYPES = () + FUNCTION = "execute" + NAME = "Save Image To Path" CATEGORY = CURRENT_CATEGORY - DESCRIPTION = "Resizes an image by specify one edge size. Proportion is kept by default." + DESCRIPTION = "TODO." - def resize(self, image, edge_size, edge_type, upscale_method): + def execute(self, images, target_folder, filename): - # get original image size - B, H, W, C = image.shape - prev_width = W - prev_height = H + target_folder = resolve_path(target_folder) - image = image.movedim(-1, 1) + if not os.path.exists(target_folder): + try: + os.makedirs(target_folder, exist_ok=True) + except Exception as e: + print(f"AG {self.NAME}: Failed to create target folder: {target_folder}") + return () + + file_path = os.path.join(target_folder, f"{filename}.png") - if edge_type: - if H < W: - new_height = edge_size - new_width = int(W * edge_size / H) - else: - new_width = edge_size - new_height = int(H * edge_size / W) - else: - if H > W: - new_height = edge_size - new_width = int(W * edge_size / H) - else: - new_width = edge_size - new_height = int(H * edge_size / W) - - image = common_upscale(image, new_width, new_height, upscale_method, "disabled") - image = image.movedim(1, -1) - - log = f"Resized from W{prev_width}xH{prev_height} to W{new_width}xH{new_height}." - - return (image, prev_width, prev_height, log,) + i = 255. * images[0].cpu().numpy() + image = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + image.save(file_path, compress_level=4) + print(f"AG {self.NAME}: {file_path} created.") + return () # need to return something to avoid error in comfyui #---------------------------------------------------------------------------------------------------------------------# -class Image2BW: +@comfy_node +class FilenameList: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "num_files": ("INT", { + "default": 10, + "min": 1, + "tooltip": "Number of filenames to generate." + }), + "filename_prefix": ("STRING", { + "default": "AG", + "tooltip": "Prefix for the filenames." + }), + "num_digit": ("INT", { + "default": 4, + "tooltip": "Number of digits in the filename." + }), + "num_start": ("INT", { + "default": 0, + "min": 0, + "tooltip": "Starting number for the filename." + }), + }, + "optional": { + "optional_idx": ("INT", { + "default": -1, + "min": -1, + "tooltip": "If specified (not -1), generate only the filename for this index." + }), + }, + } + + RETURN_TYPES = (ANY,) + RETURN_NAMES = ("filenames",) + FUNCTION = "execute" + NAME = "Filename List" + CATEGORY = CURRENT_CATEGORY + DESCRIPTION = "Generates a list of filenames based on the given prefix and number format." + + def execute(self, num_files: int, filename_prefix: str, num_digit: int, num_start: int, optional_idx: int = -1): + if optional_idx == -1: + filenames = [f"{filename_prefix}_{i:0{num_digit}}.png" for i in range(num_start, num_start + num_files)] + else: + filenames = f"{filename_prefix}_{optional_idx:0{num_digit}}.png" + return (filenames,) + +#---------------------------------------------------------------------------------------------------------------------# +@comfy_node +class FilepathGeneratorMaker: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "target_folder": ("STRING", { + "default": "%USERPROFILE%", + "tooltip": "Target folder to enumerate files from. Subfolders are not included." + }), + "file_extension": ("STRING", { + "default": "", + "tooltip": "File extension to filter by. If empty, all 'png/jpg/jpeg' are included." + }), + }, + } + + RETURN_TYPES = (ANY,) + RETURN_NAMES = ("enumerator",) + FUNCTION = "execute" + NAME = "Filepath Generator Maker" + CATEGORY = CURRENT_CATEGORY + DESCRIPTION = "Returns a generator for filepaths from the specified target folder with the given file extension." + + def execute(self, target_folder, file_extension): + + target_folder = resolve_path(target_folder) + if not os.path.exists(target_folder): + print(f"AG {self.NAME}: Target folder does not exist: {target_folder}") + return (None,) + + from pathlib import Path + + def maker(): + nonlocal target_folder + nonlocal file_extension + def file_generator(folder, extension): + path = Path(folder) + if extension != "": + yield from path.rglob(f"*.{extension.lstrip('.')}") # ensure extension starts with '.' + else: + for ext in ['*.png', '*.jpg', '*.jpeg']: + yield from path.rglob(ext) + + return file_generator(target_folder, file_extension) + + return (maker,) + +#---------------------------------------------------------------------------------------------------------------------# +@comfy_node +class IterateItems: def __init__(self): - pass + self.index = 0 @classmethod def INPUT_TYPES(cls): return { "required": { - "image": ("IMAGE",{ - "lazy": False + "items": (ANY, { + "tooltip": "Input a list to iterate over its items." + }), + "iteration_on": ("BOOLEAN", { + "default": True, + "tooltip": "Always re-run this node, even if the input list is unchanged." }), - "reds":("INT", {"default": 40, "min": -200, "max": 300, "display": "slider"}), - "yellows":("INT", {"default": 60, "min": -200, "max": 300, "display": "slider"}), - "greens":("INT", {"default": 40, "min": -200, "max": 300, "display": "slider"}), - "cyans":("INT", {"default": 60, "min": -200, "max": 300, "display": "slider"}), - "blues":("INT", {"default": 20, "min": -200, "max": 300, "display": "slider"}), - "magentas":("INT", {"default": 80, "min": -200, "max": 300, "display": "slider"}), }, + "hidden": { + "unique_id": "UNIQUE_ID", + } } - RETURN_TYPES = ("IMAGE", ) - RETURN_NAMES = ("IMAGE", ) - FUNCTION = "bw" - NAME = "Image to BW" + RETURN_TYPES = (ANY, "INT", ) + RETURN_NAMES = ("item", "index", ) + FUNCTION = "execute" + NAME = "Iterate Items" CATEGORY = CURRENT_CATEGORY - DESCRIPTION = "Converts an image to black & white like the adjustments in Photoshop." + DESCRIPTION = "Iterate over each item in the input list. Outputs one item at a time." - def bw(self, image, reds, yellows, greens, cyans, blues, magentas): + INPUT_IS_LIST = False + OUTPUT_IS_LIST = (False, False, ) - datatype = torch.float32 - # get original image size - B, H, W, C = image.shape - - color_ratios = torch.tensor([reds, greens, blues, cyans, magentas, yellows], dtype=datatype) - color_ratios = (color_ratios + 200) / 500 - - sorted_vals, sorted_idx = torch.sort(image, dim=3, descending=True) # BxHxWxC - - # Get the color ratio value of the maximum channel - max_idx = sorted_idx[:, :, :, 0].unsqueeze(-1) # BxHxWx1 - ratios_reshaped = color_ratios.unsqueeze(0).unsqueeze(0).unsqueeze(0).expand(B,H,W,-1) # BxHxWx6 - ratio_max = torch.gather(ratios_reshaped, 3, max_idx) # BxHxWx1 - - combo_idx = (sorted_idx[:, :, :, -1] + 3).unsqueeze(-1) # BxHxWx1 - ratio_combo = torch.gather(ratios_reshaped, 3, combo_idx) # BxHxWx1 - - # duplicate sorted_vals to a new tensor - sorted_vals_2 = sorted_vals.clone() - sorted_vals_2[:, :, :, 0] = 0 - - # move the first value to the last in sorted_vals_2 - sorted_vals_2 = torch.cat((sorted_vals_2[:, :, :, 1:], sorted_vals_2[:, :, :, 0].unsqueeze(-1)), dim=-1) # BxHxWxC + def execute(self, items, iteration_on, unique_id=None): + # sanity check + if not (isinstance(items, list) or isinstance(items, tuple)): + print("Input is not a list or tuple, return as is.") + return (items, 0, ) - diff = sorted_vals - sorted_vals_2 # BxHxWxC - ratio = torch.cat((ratio_max, ratio_combo, torch.ones_like(ratio_max, dtype=datatype)), dim=-1) # BxHxWx3 + # get list item + item = items[self.index] + print(f"AG {self.NAME}: iterated {self.index + 1} times.") - # calculate the sum product of diff and ratio - final = torch.sum(diff * ratio,dim=-1) # BxHxWx1 + # import json + # print(json.dumps(current_item)) # for debugging - return (final, ) + # ----------------------------------------------------- + # hack the prompt queue to add a new item to the queue + # ----------------------------------------------------- + if iteration_on: + queue = PromptServer.instance.prompt_queue + current_item = queue.currently_running[queue.task_counter - 1] + + # item structure: (number, prompt_id, prompt, extra_data, outputs_to_execute, sensitive) + new_item = list(current_item) # copy current item + new_item[0] = queue.task_counter # update index + new_item[1] = str(uuid.uuid4()) # generate new prompt_id + new_item[3]["create_time"] = int(time.time() * 1000) # timestamp in milliseconds + + # add new item to queue + if self.index < len(items) - 1: + queue.put(new_item) + + current_idx = self.index + self.index = (self.index + 1) % len(items) # will return to 0 when finished + + return (item, current_idx, ) + + return (item, 0, ) + + @classmethod + def IS_CHANGED(cls, items, iteration_on, unique_id=None): + if iteration_on: + return float("nan") # for faster update + + input_str = f"{items}-{iteration_on}" + return hashlib.sha256(input_str.encode("utf-8")).hexdigest() + +#---------------------------------------------------------------------------------------------------------------------# +@comfy_node +class IterateGenerator: + def __init__(self): + self.index = 0 + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "generatorMaker": (ANY, { + "tooltip": "Input a generator to iterate" + }), + "enabled": ("BOOLEAN", { + "default": True, + "tooltip": "Only run when enabled." + }), + }, + "hidden": { + "unique_id": "UNIQUE_ID", + } + } + + RETURN_TYPES = (ANY, "INT") + RETURN_NAMES = ("item", "index") + FUNCTION = "execute" + NAME = "Iterate Generator" + CATEGORY = CURRENT_CATEGORY + DESCRIPTION = "Iterate over each item from the generator. Outputs one item at a time." + + INPUT_IS_LIST = False + OUTPUT_IS_LIST = (False,False) + + def execute(self, generatorMaker, enabled, unique_id=None): + # when not enabled, exit + if not enabled: + print(f"AG {self.NAME}: Iterator not enabled.") + return (None, 0, ) + + # store generator at start + if self.index == 0: + self.generator = generatorMaker() + + # iterate + try: + item = next(self.generator) + except StopIteration: + self.index = 0 + print(f"AG {self.NAME}: StopIteration encountered.") + return (None, 0, ) + + # get list item + print(f"AG {self.NAME}: iterated {self.index + 1} times.") + + # ----------------------------------------------------- + # hack the prompt queue to add a new item to the queue + # ----------------------------------------------------- + queue = PromptServer.instance.prompt_queue + current_item = queue.currently_running[queue.task_counter - 1] + + # item structure: (number, prompt_id, prompt, extra_data, outputs_to_execute, sensitive) + new_item = list(current_item) # copy current item + new_item[0] = queue.task_counter # update index + new_item[1] = str(uuid.uuid4()) # generate new prompt_id + new_item[3]["create_time"] = int(time.time() * 1000) # timestamp in milliseconds + + # add new item to queue + queue.put(new_item) + + # return index + current_idx = self.index + self.index += 1 + + return (item, current_idx, ) + + @classmethod + def IS_CHANGED(cls, generatorMaker, enabled, unique_id=None): + if enabled: + return float("nan") # enforce update + return "unchanged" # don't update \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 566aec4..c39ec2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "archigraph" description = "Implementation of architectural related graph algorithm in ComfyUI." -version = "0.0.6" +version = "1.0.6" license = {file = "LICENSE"} [project.urls]