From a54056f77e7812ddfb4cd77563bcf91ca300105c Mon Sep 17 00:00:00 2001 From: Dion Timmer Date: Tue, 28 Mar 2023 00:57:43 -0400 Subject: [PATCH] init --- README.md | 29 ++- custom_nodes/DT_Chromatic_Abbreviation.py | 236 ++++++++++++++++++++++ custom_nodes/DT_FontText.py | 94 +++++++++ custom_nodes/DT_Pixel_Sort.py | 83 ++++++++ custom_nodes/DT_Solid_Color.py | 61 ++++++ custom_nodes/DT_Swap_Color_Mode.py | 67 ++++++ 6 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 custom_nodes/DT_Chromatic_Abbreviation.py create mode 100644 custom_nodes/DT_FontText.py create mode 100644 custom_nodes/DT_Pixel_Sort.py create mode 100644 custom_nodes/DT_Solid_Color.py create mode 100644 custom_nodes/DT_Swap_Color_Mode.py diff --git a/README.md b/README.md index 868d3ff..56767ce 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,29 @@ # ComfyUI Vextra Nodes - Custom nodes for ComfyUI + Custom nodes for [ComfyUI](https://github.com/comfyanonymous/ComfyUI). + +## Installation +1. Install [ComfyUI](https://github.com/comfyanonymous/ComfyUI) +2. Download nodes and place them in custom_nodes folder inside your ComfyUI installation. +3. Start/Restart ComfyUI + +## Nodes + +### Pixel Sort +Pixel sorting effect by [satyarth](https://github.com/satyarth/pixelsort). Will install pixelsort module upon installation. +Read about pixel sorting [here](http://satyarth.me/articles/pixel-sorting/). + +### Swap Color Mode (Black & White) +Swap the color mode to luminescence or single channel, useful for making mask or simply making stuff black & white. + +### Solid Color +Generates an empty solid color image with options for color, size and batch size. + +### Chromatic Aberration +Chromatic Abarration adds a color shift to the image. Useful for making the image look more "analog". + +### Add Text To Image +Supply the path to a .ttf file and add text to an image input. Has options for achor placement, rotation, color and more. + + +## Citations +Pixel sort by: [satyarth](https://github.com/satyarth) \ No newline at end of file diff --git a/custom_nodes/DT_Chromatic_Abbreviation.py b/custom_nodes/DT_Chromatic_Abbreviation.py new file mode 100644 index 0000000..2bb87b8 --- /dev/null +++ b/custom_nodes/DT_Chromatic_Abbreviation.py @@ -0,0 +1,236 @@ +import torch +import numpy as np +from PIL import Image +import math + +class Chromatic_Aberration(): + """ + This node provides a simple interface to apply PixelSort blur to the output image. + """ + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + """ + Input Types + """ + return { + "required": { + "images": ("IMAGE",),}, + "optional": { + "chromatic_strength": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 10.0, "step": 0.1}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "do_chromatic" + + CATEGORY = "VextraNodes" + + def tensor_to_pil(self, img): + if img is not None: + i = 255. * img.cpu().numpy().squeeze() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + return img + + def do_chromatic(self, images, chromatic_strength=0): + #create empty tensor with the same shape as images + total_images = [] + for image in images: + image = self.tensor_to_pil(image) + image = add_chromatic(image, chromatic_strength) + # convert to tensor + out_image = np.array(image.convert("RGB")).astype(np.float32) / 255.0 + out_image = torch.from_numpy(out_image).unsqueeze(0) + total_images.append(out_image) + + + total_images = torch.cat(total_images, 0) + return (total_images,) + +def add_chromatic(im, strength: float = 0): + if strength == 0: + return im + r, g, b = im.split() + rdata = np.asarray(r) + rfinal = r + gfinal = g + bfinal = b + + # enlarge the green and blue channels slightly, blue being the most enlarged + gfinal = gfinal.resize((round((1 + 0.018 * strength) * rdata.shape[1]), + round((1 + 0.018 * strength) * rdata.shape[0])), Image.ANTIALIAS) + bfinal = bfinal.resize((round((1 + 0.044 * strength) * rdata.shape[1]), + round((1 + 0.044 * strength) * rdata.shape[0])), Image.ANTIALIAS) + + rwidth, rheight = rfinal.size + gwidth, gheight = gfinal.size + bwidth, bheight = bfinal.size + rhdiff = (bheight - rheight) // 2 + rwdiff = (bwidth - rwidth) // 2 + ghdiff = (bheight - gheight) // 2 + gwdiff = (bwidth - gwidth) // 2 + + # Centre the channels + im = Image.merge("RGB", ( + rfinal.crop((-rwdiff, -rhdiff, bwidth - rwdiff, bheight - rhdiff)), + gfinal.crop((-gwdiff, -ghdiff, bwidth - gwdiff, bheight - ghdiff)), + bfinal)) + + # Crop the image to the original image dimensions + return im.crop((rwdiff, rhdiff, rwidth + rwdiff, rheight + rhdiff)) + + + +NODE_CLASS_MAPPINGS = { + "Chromatic Aberration": Chromatic_Aberration +} + + +def cartesian_to_polar(data: np.ndarray) -> np.ndarray: + """Returns the polar form of + """ + width = data.shape[1] + height = data.shape[0] + assert (width > 2) + assert (height > 2) + assert (width % 2 == 1) + assert (height % 2 == 1) + perimeter = 2 * (width + height - 2) + halfdiag = math.ceil(((width ** 2 + height ** 2) ** 0.5) / 2) + halfw = width // 2 + halfh = height // 2 + ret = np.zeros((halfdiag, perimeter, 3)) + + # Don't want to deal with divide by zero errors... + ret[0:(halfw + 1), halfh] = data[halfh, halfw::-1] + ret[0:(halfw + 1), height + width - 2 + + halfh] = data[halfh, halfw:(halfw * 2 + 1)] + ret[0:(halfh + 1), height - 1 + halfw] = data[halfh:(halfh * 2 + 1), halfw] + ret[0:(halfh + 1), perimeter - halfw] = data[halfh::-1, halfw] + + # Divide the image into 8 triangles, and use the same calculation on + # 4 triangles at a time. This is possible due to symmetry. + # This section is also responsible for the corner pixels + for i in range(0, halfh): + slope = (halfh - i) / (halfw) + diagx = ((halfdiag ** 2) / (slope ** 2 + 1)) ** 0.5 + unit_xstep = diagx / (halfdiag - 1) + unit_ystep = diagx * slope / (halfdiag - 1) + for row in range(halfdiag): + ystep = round(row * unit_ystep) + xstep = round(row * unit_xstep) + if ((halfh >= ystep) and halfw >= xstep): + ret[row, i] = data[halfh - ystep, halfw - xstep] + ret[row, height - 1 - i] = data[halfh + ystep, halfw - xstep] + ret[row, height + width - 2 + + i] = data[halfh + ystep, halfw + xstep] + ret[row, height + width + height - 3 - + i] = data[halfh - ystep, halfw + xstep] + else: + break + + # Remaining 4 triangles + for j in range(1, halfw): + slope = (halfh) / (halfw - j) + diagx = ((halfdiag ** 2) / (slope ** 2 + 1)) ** 0.5 + unit_xstep = diagx / (halfdiag - 1) + unit_ystep = diagx * slope / (halfdiag - 1) + for row in range(halfdiag): + ystep = round(row * unit_ystep) + xstep = round(row * unit_xstep) + if (halfw >= xstep and halfh >= ystep): + ret[row, height - 1 + j] = data[halfh + ystep, halfw - xstep] + ret[row, height + width - 2 - + j] = data[halfh + ystep, halfw + xstep] + ret[row, height + width + height - 3 + + j] = data[halfh - ystep, halfw + xstep] + ret[row, perimeter - j] = data[halfh - ystep, halfw - xstep] + else: + break + return ret + + +def polar_to_cartesian(data: np.ndarray, width: int, height: int) -> np.ndarray: + """Returns the cartesian form of . + + is the original width of the cartesian image + is the original height of the cartesian image + """ + assert (width > 2) + assert (height > 2) + assert (width % 2 == 1) + assert (height % 2 == 1) + perimeter = 2 * (width + height - 2) + halfdiag = math.ceil(((width ** 2 + height ** 2) ** 0.5) / 2) + halfw = width // 2 + halfh = height // 2 + ret = np.zeros((height, width, 3)) + + def div0(): + # Don't want to deal with divide by zero errors... + ret[halfh, halfw::-1] = data[0:(halfw + 1), halfh] + ret[halfh, halfw:(halfw * 2 + 1)] = data[0:(halfw + 1), + height + width - 2 + halfh] + ret[halfh:(halfh * 2 + 1), halfw] = data[0:(halfh + 1), height - 1 + halfw] + ret[halfh::-1, halfw] = data[0:(halfh + 1), perimeter - halfw] + + div0() + + # Same code as above, except the order of the assignments are switched + # Code blocks are split up for easier profiling + def part1(): + for i in range(0, halfh): + slope = (halfh - i) / (halfw) + diagx = ((halfdiag ** 2) / (slope ** 2 + 1)) ** 0.5 + unit_xstep = diagx / (halfdiag - 1) + unit_ystep = diagx * slope / (halfdiag - 1) + for row in range(halfdiag): + ystep = round(row * unit_ystep) + xstep = round(row * unit_xstep) + if ((halfh >= ystep) and halfw >= xstep): + ret[halfh - ystep, halfw - xstep] = \ + data[row, i] + ret[halfh + ystep, halfw - xstep] = \ + data[row, height - 1 - i] + ret[halfh + ystep, halfw + xstep] = \ + data[row, height + width - 2 + i] + ret[halfh - ystep, halfw + xstep] = \ + data[row, height + width + height - 3 - i] + else: + break + + part1() + + def part2(): + for j in range(1, halfw): + slope = (halfh) / (halfw - j) + diagx = ((halfdiag ** 2) / (slope ** 2 + 1)) ** 0.5 + unit_xstep = diagx / (halfdiag - 1) + unit_ystep = diagx * slope / (halfdiag - 1) + for row in range(halfdiag): + ystep = round(row * unit_ystep) + xstep = round(row * unit_xstep) + if (halfw >= xstep and halfh >= ystep): + ret[halfh + ystep, halfw - xstep] = \ + data[row, height - 1 + j] + ret[halfh + ystep, halfw + xstep] = \ + data[row, height + width - 2 - j] + ret[halfh - ystep, halfw + xstep] = \ + data[row, height + width + height - 3 + j] + ret[halfh - ystep, halfw - xstep] = \ + data[row, perimeter - j] + else: + break + + part2() + + # Repairs black/missing pixels in the transformed image + def set_zeros(): + zero_mask = ret[1:-1, 1:-1] == 0 + ret[1:-1, 1:-1] = np.where(zero_mask, (ret[:-2, 1:-1] + ret[2:, 1:-1]) / 2, ret[1:-1, 1:-1]) + + set_zeros() + + return ret \ No newline at end of file diff --git a/custom_nodes/DT_FontText.py b/custom_nodes/DT_FontText.py new file mode 100644 index 0000000..516e2ba --- /dev/null +++ b/custom_nodes/DT_FontText.py @@ -0,0 +1,94 @@ +import torch +import numpy as np +from PIL import Image +from PIL import ImageDraw +from PIL import ImageFont + +class FontText(): + """ + This node provides a simple interface to apply PixelSort blur to the output image. + """ + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + """ + Input Types + """ + return { + "required": { + "images": ("IMAGE",),}, + "optional": { + "font_ttf": ("STRING", {"default": 'C:/Windows/Fonts/arial.ttf'}), + "size": ("INT", {"default": 50, "min": 2, "max": 1000, "step": 1}), + "x": ("INT", {"default": 50, "min": 2, "max": 10000, "step": 1}), + "y": ("INT", {"default": 50, "min": 2, "max": 10000, "step": 1}), + "text": ("STRING", {"default": "Hello World"}), + "color": ("STRING", {"default": 'rgb(255, 255, 255)'}), + "anchor": (["Bottom Left Corner", "Center"],), + "rotate": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 360.0, "step": 0.1}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "do_font" + + CATEGORY = "VextraNodes" + + def tensor_to_pil(self, img): + if img is not None: + i = 255. * img.cpu().numpy().squeeze() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + return img + + def do_font(self, images, font_ttf, size, x, y, text, color, anchor, rotate): + #create empty tensor with the same shape as images + total_images = [] + center_anchor = True if anchor == 'Center' else False + if color.startswith('#'): + color_rgb = tuple(int(color.lstrip('#')[i:i+2], 16) for i in (0, 2, 4)) + else: + color_rgb = tuple(map(int, color.strip('rgb()').split(','))) + for image in images: + image = self.tensor_to_pil(image) + + add_text_to_image(image, font_ttf, size, x, y, text, color_rgb, center_anchor, rotate) + + + + + + + # convert to tensor + out_image = np.array(image.convert("RGB")).astype(np.float32) / 255.0 + out_image = torch.from_numpy(out_image).unsqueeze(0) + total_images.append(out_image) + + + total_images = torch.cat(total_images, 0) + return (total_images,) + +NODE_CLASS_MAPPINGS = { + "Add Text To Image": FontText +} + +def add_text_to_image(img, font_ttf, size, x, y, text, color_rgb, center=False, rotate=0): + draw = ImageDraw.Draw(img) + myFont = ImageFont.truetype(font_ttf, size) + text_width, text_height = draw.textsize(text, font=myFont) + + if center: + x -= text_width // 2 + y -= text_height // 2 + + if rotate != 0: + text_img = Image.new('RGBA', img.size, (255, 255, 255, 0)) + text_draw = ImageDraw.Draw(text_img) + text_draw.text((x, y), text, font=myFont, fill=color_rgb) + text_img = text_img.rotate(rotate, resample=Image.BICUBIC, expand=True) + img.paste(text_img, (0, 0), text_img) + else: + draw.text((x, y), text, font=myFont, fill=color_rgb) + + return img diff --git a/custom_nodes/DT_Pixel_Sort.py b/custom_nodes/DT_Pixel_Sort.py new file mode 100644 index 0000000..b228506 --- /dev/null +++ b/custom_nodes/DT_Pixel_Sort.py @@ -0,0 +1,83 @@ +import torch +import numpy as np +from PIL import Image +import sys +import subprocess +try: + import pixelsort +except ModuleNotFoundError: + # install pixelsort in current venv + subprocess.check_call([sys.executable, "-m", "pip", "install", "pixelsort"]) + import pixelsort + + +class Pixel_Sort: + """ + This node provides a simple interface to apply PixelSort blur to the output image. + """ + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + """ + Input Types + """ + return { + "required": { + "images": ("IMAGE",),}, + "optional": { + "character_length": ("INT", {"default": 50, "min": 2, "max": 1000, "step": 1}), + "randomness": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100.0, "step": 0.1}), + "sorting_function": (["lightness", "hue", "saturation", "intensity", "minimum"],), + "interval_function": (["threshold", "random", 'edges', 'waves', 'file', 'file-edges', 'none'],), + "lower_threshold": ("FLOAT", {"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.01}), + "upper_threshold": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 1.0, "step": 0.01}), + "angle": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 360.0, "step": 0.1}), + "mask_image": ("IMAGE", {"default": None}), + "interval_image": ("IMAGE", {"default": None}), + + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "do_sort" + + CATEGORY = "VextraNodes" + + def tensor_to_pil(self, img): + if img is not None: + i = 255. * img.cpu().numpy().squeeze() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + return img + + def do_sort(self, images, character_length, randomness, sorting_function, interval_function, lower_threshold, upper_threshold, angle, mask_image=None, interval_image=None, color_mode='default'): + #create empty tensor with the same shape as images + total_images = [] + for image in images: + image = self.tensor_to_pil(image) + mask_image = self.tensor_to_pil(mask_image) + interval_image = self.tensor_to_pil(interval_image) + out_image = pixelsort.pixelsort( + image=image, + mask_image=mask_image, + interval_image=interval_image, + randomness=randomness, + clength=character_length, + sorting_function=sorting_function, + interval_function=interval_function, + lower_threshold=lower_threshold, + upper_threshold=upper_threshold, + angle=angle) + # convert to tensor + out_image = np.array(out_image.convert("RGB")).astype(np.float32) / 255.0 + out_image = torch.from_numpy(out_image).unsqueeze(0) + total_images.append(out_image) + + + total_images = torch.cat(total_images, 0) + return (total_images,) + +NODE_CLASS_MAPPINGS = { + "Pixel Sort": Pixel_Sort +} \ No newline at end of file diff --git a/custom_nodes/DT_Solid_Color.py b/custom_nodes/DT_Solid_Color.py new file mode 100644 index 0000000..d4d1dd1 --- /dev/null +++ b/custom_nodes/DT_Solid_Color.py @@ -0,0 +1,61 @@ +import torch +import numpy as np +from PIL import Image +from PIL import ImageDraw +from PIL import ImageFont + +class SolidColorImage(): + """ + This node provides a simple interface to apply PixelSort blur to the output image. + """ + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + """ + Input Types + """ + return { + "required": { + "width": ("INT", {"default": 512, "min": 64, "max": 10000, "step": 64}), + "height": ("INT", {"default": 512, "min": 64, "max": 10000, "step": 64}), + "color": ("STRING", {"default": 'rgb(255, 255, 255)'}), + "batch_size": ("INT", {"default": 1, "min": 1, "max": 64, "step": 1}), + }, + "optional": { + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "do_solid" + + CATEGORY = "VextraNodes" + + def tensor_to_pil(self, img): + if img is not None: + i = 255. * img.cpu().numpy().squeeze() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + return img + + def do_solid(self, width, height, color, batch_size): + #create empty tensor with the same shape as images + total_images = [] + if color.startswith('#'): + color_rgb = tuple(int(color.lstrip('#')[i:i+2], 16) for i in (0, 2, 4)) + else: + color_rgb = tuple(map(int, color.strip('rgb()').split(','))) + for i in range(batch_size): + image = Image.new('RGB', (width, height), color_rgb) + # convert to tensor + out_image = np.array(image.convert("RGB")).astype(np.float32) / 255.0 + out_image = torch.from_numpy(out_image).unsqueeze(0) + total_images.append(out_image) + + + total_images = torch.cat(total_images, 0) + return (total_images,) + +NODE_CLASS_MAPPINGS = { + "Create Solid Color": SolidColorImage +} diff --git a/custom_nodes/DT_Swap_Color_Mode.py b/custom_nodes/DT_Swap_Color_Mode.py new file mode 100644 index 0000000..8da7b5f --- /dev/null +++ b/custom_nodes/DT_Swap_Color_Mode.py @@ -0,0 +1,67 @@ +import torch +import numpy as np +from PIL import Image + +COLOR_MODES = { + 'RGB': 'RGB', + 'RGBA': 'RGBA', + 'luminance': 'L', + 'luminance_alpha': 'LA', + 'cmyk': 'CMYK', + 'ycbcr': 'YCbCr', + 'lab': 'LAB', + 'hsv': 'HSV', + 'single_channel': '1', +} + +class Swap_Color_Mode(): + """ + This node provides a simple interface to apply PixelSort blur to the output image. + """ + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + """ + Input Types + """ + return { + "required": { + "images": ("IMAGE",),}, + "optional": { + "color_mode": (['default', 'luminance', 'single_channel'],), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "do_swap" + + CATEGORY = "VextraNodes" + + def tensor_to_pil(self, img): + if img is not None: + i = 255. * img.cpu().numpy().squeeze() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + return img + + def do_swap(self, images, color_mode='default'): + #create empty tensor with the same shape as images + total_images = [] + for image in images: + image = self.tensor_to_pil(image) + if color_mode != 'default': + correct_color_mode = COLOR_MODES[color_mode] + image = image.convert(correct_color_mode) + # convert to tensor + out_image = np.array(image.convert("RGB")).astype(np.float32) / 255.0 + out_image = torch.from_numpy(out_image).unsqueeze(0) + total_images.append(out_image) + + + total_images = torch.cat(total_images, 0) + return (total_images,) + +NODE_CLASS_MAPPINGS = { + "Swap Color Mode": Swap_Color_Mode +} \ No newline at end of file