From 14d9b11a359bcddcfda3e18d43bd8fbfcb6169e2 Mon Sep 17 00:00:00 2001 From: Silversith Date: Sat, 1 Apr 2023 21:24:46 +0200 Subject: [PATCH] Add custom nodes for image to depth map --- .gitignore | 1 - custom_nodes/WAS_Node_Suite.py | 4069 ++++++++++++++++++++++++++++++++ 2 files changed, 4069 insertions(+), 1 deletion(-) create mode 100644 custom_nodes/WAS_Node_Suite.py diff --git a/.gitignore b/.gitignore index 214e9b3a4..483830e7d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ input/ !input/example.png models/ temp/ -custom_nodes/ !custom_nodes/example_node.py.example extra_model_paths.yaml venv/ diff --git a/custom_nodes/WAS_Node_Suite.py b/custom_nodes/WAS_Node_Suite.py new file mode 100644 index 000000000..f485dbb46 --- /dev/null +++ b/custom_nodes/WAS_Node_Suite.py @@ -0,0 +1,4069 @@ +# By WASasquatch (Discord: WAS#0263) +# +# Copyright 2023 Jordan Thompson (WASasquatch) +# +# 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 PIL import Image, ImageFilter, ImageEnhance, ImageOps, ImageDraw, ImageChops, ImageFont +from PIL.PngImagePlugin import PngInfo +from io import BytesIO +from typing import Optional +from urllib.request import urlopen +import comfy.samplers +import comfy.sd +import comfy.utils +import glob +import hashlib +import json +import nodes +import numpy as np +import os +import random +import requests +import subprocess +import sys +import time +import torch + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), "comfy")) +sys.path.append('..'+os.sep+'ComfyUI') + + +# GLOBALS +NODE_FILE = os.path.abspath(__file__) +MIDAS_INSTALLED = False +CUSTOM_NODES_DIR = ( os.path.dirname(os.path.dirname(NODE_FILE)) + if os.path.dirname(os.path.dirname(NODE_FILE)) == 'was-node-suite-comfyui' + or os.path.dirname(os.path.dirname(NODE_FILE)) == 'was-node-suite-comfyui-main' + else os.path.dirname(NODE_FILE) ) +WAS_SUITE_ROOT = os.path.dirname(NODE_FILE) +WAS_DATABASE = os.path.join(WAS_SUITE_ROOT, 'was_suite_settings.json') + +# WAS Suite Locations Debug +print('\033[34mWAS Node Suite:\033[0m Running At:', NODE_FILE) +print('\033[34mWAS Node Suite:\033[0m Running From:', WAS_SUITE_ROOT) + +#! SUITE SPECIFIC CLASSES & FUNCTIONS + +# Freeze PIP modules +def packages() -> list[str]: + import sys + import subprocess + return [r.decode().split('==')[0] for r in subprocess.check_output([sys.executable, '-m', 'pip', 'freeze']).split()] + +# Tensor to PIL +def tensor2pil(image: torch.Tensor) -> Image.Image: + return Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)) + +# Convert PIL to Tensor +def pil2tensor(image: Image.Image) -> torch.Tensor: + return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0) + +# PIL Hex +def pil2hex(image: torch.Tensor) -> str: + return hashlib.sha256(np.array(tensor2pil(image)).astype(np.uint16).tobytes()).hexdigest() + +# Median Filter +def medianFilter(img, diameter, sigmaColor, sigmaSpace): + import cv2 as cv + diameter = int(diameter) + sigmaColor = int(sigmaColor) + sigmaSpace = int(sigmaSpace) + img = img.convert('RGB') + img = cv.cvtColor(np.array(img), cv.COLOR_RGB2BGR) + img = cv.bilateralFilter(img, diameter, sigmaColor, sigmaSpace) + img = cv.cvtColor(np.array(img), cv.COLOR_BGR2RGB) + return Image.fromarray(img).convert('RGB') + +# WAS SETTINGS MANAGER + +class WASDatabase: + """ + The WAS Suite Database Class provides a simple key-value database that stores + data in a flatfile using the JSON format. Each key-value pair is associated with + a category. + + Attributes: + filepath (str): The path to the JSON file where the data is stored. + data (dict): The dictionary that holds the data read from the JSON file. + + Methods: + insert(category, key, value): Inserts a key-value pair into the database + under the specified category. + get(category, key): Retrieves the value associated with the specified + key and category from the database. + update(category, key): Update a value associated with the specified + key and category from the database. + delete(category, key): Deletes the key-value pair associated with the + specified key and category from the database. + _save(): Saves the current state of the database to the JSON file. + """ + def __init__(self, filepath): + self.filepath = filepath + try: + with open(filepath, 'r') as f: + self.data = json.load(f) + except FileNotFoundError: + self.data = {} + + def insert(self, category, key, value): + if category not in self.data: + self.data[category] = {} + self.data[category][key] = value + self._save() + + def update(self, category, key, value): + if category in self.data and key in self.data[category]: + self.data[category][key] = value + self._save() + + def get(self, category, key): + return self.data.get(category, {}).get(key, None) + + def delete(self, category, key): + if category in self.data and key in self.data[category]: + del self.data[category][key] + self._save() + + def _save(self): + try: + with open(self.filepath, 'w') as f: + json.dump(self.data, f) + except FileNotFoundError: + print(f"\033[34mWAS Node Suite\033[0m Warning: Cannot save database to file '{self.filepath}'." + " Storing the data in the object instead. Does the folder and node file have write permissions?") + +# Initialize the settings database +WDB = WASDatabase(WAS_DATABASE) + +class WAS_Filter_Class(): + + # TOOLS + + def fig2img(self, plot): + import io + buf = io.BytesIO() + plot.savefig(buf) + buf.seek(0) + img = Image.open(buf) + return img + + # FILTERS + + # Sparkle - Fairy Tale Filter + + def sparkle(self, image): + + import pilgram + + image = image.convert('RGBA') + contrast_enhancer = ImageEnhance.Contrast(image) + image = contrast_enhancer.enhance(1.25) + saturation_enhancer = ImageEnhance.Color(image) + image = saturation_enhancer.enhance(1.5) + + bloom = image.filter(ImageFilter.GaussianBlur(radius=20)) + bloom = ImageEnhance.Brightness(bloom).enhance(1.2) + bloom.putalpha(128) + bloom = bloom.convert(image.mode) + image = Image.alpha_composite(image, bloom) + + width, height = image.size + # Particls A + particles = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(particles) + for i in range(5000): + x = random.randint(0, width) + y = random.randint(0, height) + r = random.randint(0, 255) + g = random.randint(0, 255) + b = random.randint(0, 255) + draw.point((x, y), fill=(r, g, b, 255)) + particles = particles.filter(ImageFilter.GaussianBlur(radius=1)) + particles.putalpha(128) + + particles2 = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(particles2) + for i in range(5000): + x = random.randint(0, width) + y = random.randint(0, height) + r = random.randint(0, 255) + g = random.randint(0, 255) + b = random.randint(0, 255) + draw.point((x, y), fill=(r, g, b, 255)) + particles2 = particles2.filter(ImageFilter.GaussianBlur(radius=1)) + particles2.putalpha(128) + + image = pilgram.css.blending.color_dodge(image, particles) + image = pilgram.css.blending.lighten(image, particles2) + + return image + + def digital_distortion(self, image, amplitude=5, line_width=2): + # Convert the PIL image to a numpy array + im = np.array(image) + + # Create a sine wave with the given amplitude + x, y, z = im.shape + sine_wave = amplitude * np.sin(np.linspace(-np.pi, np.pi, y)) + sine_wave = sine_wave.astype(int) + + # Create the left and right distortion matrices + left_distortion = np.zeros((x, y, z), dtype=np.uint8) + right_distortion = np.zeros((x, y, z), dtype=np.uint8) + for i in range(y): + left_distortion[:, i, :] = np.roll(im[:, i, :], -sine_wave[i], axis=0) + right_distortion[:, i, :] = np.roll(im[:, i, :], sine_wave[i], axis=0) + + # Combine the distorted images and add scan lines as a mask + distorted_image = np.maximum(left_distortion, right_distortion) + scan_lines = np.zeros((x, y), dtype=np.float32) + scan_lines[::line_width, :] = 1 + scan_lines = np.minimum(scan_lines * amplitude*50.0, 1) # Scale scan line values + scan_lines = np.tile(scan_lines[:, :, np.newaxis], (1, 1, z)) # Add channel dimension + distorted_image = np.where(scan_lines > 0, np.random.permutation(im), distorted_image) + distorted_image = np.roll(distorted_image, np.random.randint(0, y), axis=1) + + # Convert the numpy array back to a PIL image + distorted_image = Image.fromarray(distorted_image) + + return distorted_image + + def signal_distortion(self, image, amplitude): + # Convert the image to a numpy array for easy manipulation + img_array = np.array(image) + + # Generate random shift values for each row of the image + row_shifts = np.random.randint(-amplitude, amplitude + 1, size=img_array.shape[0]) + + # Create an empty array to hold the distorted image + distorted_array = np.zeros_like(img_array) + + # Loop through each row of the image + for y in range(img_array.shape[0]): + # Determine the X-axis shift value for this row + x_shift = row_shifts[y] + + # Use modular function to determine where to shift + x_shift = x_shift + y % (amplitude * 2) - amplitude + + # Shift the pixels in this row by the X-axis shift value + distorted_array[y,:] = np.roll(img_array[y,:], x_shift, axis=0) + + # Convert the distorted array back to a PIL image + distorted_image = Image.fromarray(distorted_array) + + return distorted_image + + def tv_vhs_distortion(self, image, amplitude=10): + # Convert the PIL image to a NumPy array. + np_image = np.array(image) + + # Generate random shift values for each row of the image + offset_variance = int(image.height / amplitude) + row_shifts = np.random.randint(-offset_variance, offset_variance + 1, size=image.height) + + # Create an empty array to hold the distorted image + distorted_array = np.zeros_like(np_image) + + # Loop through each row of the image + for y in range(np_image.shape[0]): + # Determine the X-axis shift value for this row + x_shift = row_shifts[y] + + # Use modular function to determine where to shift + x_shift = x_shift + y % (offset_variance * 2) - offset_variance + + # Shift the pixels in this row by the X-axis shift value + distorted_array[y,:] = np.roll(np_image[y,:], x_shift, axis=0) + + # Apply distortion and noise to the image using NumPy functions. + h, w, c = distorted_array.shape + x_scale = np.linspace(0, 1, w) + y_scale = np.linspace(0, 1, h) + x_idx = np.broadcast_to(x_scale, (h, w)) + y_idx = np.broadcast_to(y_scale.reshape(h, 1), (h, w)) + noise = np.random.rand(h, w, c) * 0.1 + distortion = np.sin(x_idx * 50) * 0.5 + np.sin(y_idx * 50) * 0.5 + distorted_array = distorted_array + distortion[:, :, np.newaxis] + noise + + # Convert the distorted array back to a PIL image + distorted_image = Image.fromarray(np.uint8(distorted_array)) + distorted_image = distorted_image.resize((image.width, image.height)) + + # Apply color enhancement to the original image. + image_enhance = ImageEnhance.Color(image) + image = image_enhance.enhance(0.5) + + # Overlay the distorted image over the original image. + effect_image = ImageChops.overlay(image, distorted_image) + result_image = ImageChops.overlay(image, effect_image) + result_image = ImageChops.blend(image, result_image, 0.25) + + return result_image + + def gradient(self, size, mode='horizontal', colors=None, tolerance=0): + # Parse colors as JSON if it is a string + if isinstance(colors, str): + colors = json.loads(colors) + + colors = {int(k): [int(c) for c in v] for k, v in colors.items()} + + # Set default colors if not provided + if colors is None: + colors = {0:[255,0,0],50:[0,255,0],100:[0,0,255]} + + # Create a new image with a black background + img = Image.new('RGB', size, color=(0, 0, 0)) + + # Determine the color spectrum between the color stops + color_stop_positions = sorted(colors.keys()) + color_stop_count = len(color_stop_positions) + color_stop_index = 0 + spectrum = [] + for i in range(256): + if color_stop_index < color_stop_count - 1 and i > int(color_stop_positions[color_stop_index + 1]): + color_stop_index += 1 + start_pos = color_stop_positions[color_stop_index] + end_pos = color_stop_positions[color_stop_index + 1] if color_stop_index < color_stop_count - 1 else start_pos + start = colors[start_pos] + end = colors[end_pos] + if end_pos - start_pos == 0: + r, g, b = start + else: + r = round(start[0] + (i - start_pos) * (end[0] - start[0]) / (end_pos - start_pos)) + g = round(start[1] + (i - start_pos) * (end[1] - start[1]) / (end_pos - start_pos)) + b = round(start[2] + (i - start_pos) * (end[2] - start[2]) / (end_pos - start_pos)) + spectrum.append((r, g, b)) + + # Draw the gradient + draw = ImageDraw.Draw(img) + if mode == 'horizontal': + for x in range(size[0]): + pos = int(x * 100 / (size[0] - 1)) + color = spectrum[pos] + if tolerance > 0: + color = tuple([round(c / tolerance) * tolerance for c in color]) + draw.line((x, 0, x, size[1]), fill=color) + elif mode == 'vertical': + for y in range(size[1]): + pos = int(y * 100 / (size[1] - 1)) + color = spectrum[pos] + if tolerance > 0: + color = tuple([round(c / tolerance) * tolerance for c in color]) + draw.line((0, y, size[0], y), fill=color) + + return img + + + # Version 2 optimized based on Mark Setchell's ideas + def gradient_map(self, image, gradient_map, reverse=False): + + # Reverse the image + if reverse: + gradient_map = gradient_map.transpose(Image.FLIP_LEFT_RIGHT) + + # Convert image to Numpy array and average RGB channels + na = np.array(image) + grey = np.mean(na, axis=2).astype(np.uint8) + + # Convert gradient map to Numpy array + cmap = np.array(gradient_map.convert('RGB')) + + # Make output image, same height and width as grey image, but 3-channel RGB + result = np.zeros((*grey.shape, 3), dtype=np.uint8) + + # Reshape grey to match the shape of result + grey_reshaped = grey.reshape(-1) + + # Take entries from RGB gradient map according to grayscale values in image + np.take(cmap.reshape(-1, 3), grey_reshaped, axis=0, out=result.reshape(-1, 3)) + + # Convert result to PIL image + result_image = Image.fromarray(result) + + return result_image + + + # Analyze Filters + + def black_white_levels(self, image): + + if 'matplotlib' not in packages(): + print("\033[34mWAS NS:\033[0m Installing matplotlib...") + subprocess.check_call([sys.executable, '-m', 'pip', '-q', 'install', 'matplotlib']) + + import matplotlib.pyplot as plt + + # convert to grayscale + image = image.convert('L') + + # Calculate the histogram of grayscale intensities + hist = image.histogram() + + # Find the minimum and maximum grayscale intensity values + min_val = 0 + max_val = 255 + for i in range(256): + if hist[i] > 0: + min_val = i + break + for i in range(255, -1, -1): + if hist[i] > 0: + max_val = i + break + + # Create a graph of the grayscale histogram + plt.figure(figsize=(16, 8)) + plt.hist(image.getdata(), bins=256, range=(0, 256), color='black', alpha=0.7) + plt.xlim([0, 256]) + plt.ylim([0, max(hist)]) + plt.axvline(min_val, color='red', linestyle='dashed') + plt.axvline(max_val, color='red', linestyle='dashed') + plt.title('Black and White Levels') + plt.xlabel('Intensity') + plt.ylabel('Frequency') + + return self.fig2img(plt) + + def channel_frequency(self, image): + + if 'matplotlib' not in packages(): + print("\033[34mWAS NS:\033[0m Installing matplotlib...") + subprocess.check_call([sys.executable, '-m', 'pip', '-q', 'install', 'matplotlib']) + + import matplotlib.pyplot as plt + + # Split the image into its RGB channels + r, g, b = image.split() + + # Calculate the frequency of each color in each channel + r_freq = r.histogram() + g_freq = g.histogram() + b_freq = b.histogram() + + # Create a graph to hold the frequency maps + fig, axs = plt.subplots(1, 3, figsize=(16, 4)) + axs[0].set_title('Red Channel') + axs[1].set_title('Green Channel') + axs[2].set_title('Blue Channel') + + # Plot the frequency of each color in each channel + axs[0].plot(range(256), r_freq, color='red') + axs[1].plot(range(256), g_freq, color='green') + axs[2].plot(range(256), b_freq, color='blue') + + # Set the axis limits and labels + for ax in axs: + ax.set_xlim([0, 255]) + ax.set_xlabel('Color Intensity') + ax.set_ylabel('Frequency') + + return self.fig2img(plt) + + + def generate_palette(self, img, n_colors=16, cell_size=128, padding=10, font_path=None, font_size=15): + + if 'scikit-learn' not in packages(): + print("\033[34mWAS NS:\033[0m Installing scikit-learn...") + subprocess.check_call([sys.executable, '-m', 'pip', '-q', 'install', 'scikit-learn']) + + from sklearn.cluster import KMeans + + # Resize the image to speed up processing + img = img.resize((img.width // 2, img.height // 2), resample=Image.BILINEAR) + # Convert the image to a numpy array + pixels = np.array(img) + # Flatten the pixel array to get a 2D array of RGB values + pixels = pixels.reshape((-1, 3)) + # Initialize the KMeans model with the specified number of colors + kmeans = KMeans(n_clusters=n_colors, random_state=0, n_init='auto').fit(pixels) + # Get the cluster centers and convert them to integer values + cluster_centers = np.uint8(kmeans.cluster_centers_) + # Calculate the size of the palette image based on the number of colors + palette_size = (cell_size * (int(np.sqrt(n_colors))+1)//2*2, cell_size * (int(np.sqrt(n_colors))+1)//2*2) + # Create a square image with the cluster centers as the color palette + palette = Image.new('RGB', palette_size, color='white') + draw = ImageDraw.Draw(palette) + if font_path: + font = ImageFont.truetype(font_path, font_size) + else: + font = ImageFont.load_default() + stroke_width = 1 + for i in range(n_colors): + color = tuple(cluster_centers[i]) + x = i % int(np.sqrt(n_colors)) + y = i // int(np.sqrt(n_colors)) + # Calculate the position of the cell and text + cell_x = x * cell_size + padding + cell_y = y * cell_size + padding + text_x = cell_x + ( padding / 2 ) + text_y = int(cell_y + cell_size / 1.2) - font.getsize('A')[1] - padding + # Draw the cell and text with padding + draw.rectangle((cell_x, cell_y, cell_x + cell_size - padding * 2, cell_y + cell_size - padding * 2), fill=color, outline='black', width=1) + draw.text((text_x+1, text_y+1), f"R: {color[0]} G: {color[1]} B: {color[2]}", font=font, fill='black') + draw.text((text_x, text_y), f"R: {color[0]} G: {color[1]} B: {color[2]}", font=font, fill='white') + # Resize the image back to the original size + palette = palette.resize((palette.width * 2, palette.height * 2), resample=Image.NEAREST) + return palette + +# INSTALLATION CLEANUP + +# Delete legacy nodes +legacy_was_nodes = ['fDOF_WAS.py', 'Image_Blank_WAS.py', 'Image_Blend_WAS.py', 'Image_Canny_Filter_WAS.py', 'Canny_Filter_WAS.py', 'Image_Combine_WAS.py', 'Image_Edge_Detection_WAS.py', 'Image_Film_Grain_WAS.py', 'Image_Filters_WAS.py', + 'Image_Flip_WAS.py', 'Image_Nova_Filter_WAS.py', 'Image_Rotate_WAS.py', 'Image_Style_Filter_WAS.py', 'Latent_Noise_Injection_WAS.py', 'Latent_Upscale_WAS.py', 'MiDaS_Depth_Approx_WAS.py', 'NSP_CLIPTextEncoder.py', 'Samplers_WAS.py'] +legacy_was_nodes_found = [] + +if os.path.basename(CUSTOM_NODES_DIR) == 'was-node-suite-comfyui': + legacy_was_nodes.append('WAS_Node_Suite.py') + +f_disp = False +node_path_dir = os.getcwd()+os.sep+'ComfyUI'+os.sep+'custom_nodes'+os.sep +for f in legacy_was_nodes: + file = f'{node_path_dir}{f}' + if os.path.exists(file): + if not f_disp: + print( + '\033[34mWAS Node Suite:\033[0m Found legacy nodes. Archiving legacy nodes...') + f_disp = True + legacy_was_nodes_found.append(file) +if legacy_was_nodes_found: + import zipfile + from os.path import basename + archive = zipfile.ZipFile( + f'{node_path_dir}WAS_Legacy_Nodes_Backup_{round(time.time())}.zip', "w") + for f in legacy_was_nodes_found: + archive.write(f, basename(f)) + try: + os.remove(f) + except OSError: + pass + archive.close() +if f_disp: + print('\033[34mWAS Node Suite:\033[0m Legacy cleanup complete.') + +#! IMAGE FILTER NODES + +# IMAGE FILTER ADJUSTMENTS + + +class WAS_Image_Filters: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "brightness": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}), + "contrast": ("FLOAT", {"default": 1.0, "min": -1.0, "max": 2.0, "step": 0.01}), + "saturation": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.01}), + "sharpness": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.01}), + "blur": ("INT", {"default": 0, "min": 0, "max": 16, "step": 1}), + "gaussian_blur": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1024.0, "step": 0.1}), + "edge_enhance": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_filters" + + CATEGORY = "WAS Suite/Image" + + def image_filters(self, image, brightness, contrast, saturation, sharpness, blur, gaussian_blur, edge_enhance): + + pil_image = None + + # Apply NP Adjustments + if brightness > 0.0 or brightness < 0.0: + # Apply brightness + image = np.clip(image + brightness, 0.0, 1.0) + + if contrast > 1.0 or contrast < 1.0: + # Apply contrast + image = np.clip(image * contrast, 0.0, 1.0) + + # Apply PIL Adjustments + if saturation > 1.0 or saturation < 1.0: + # PIL Image + pil_image = tensor2pil(image) + # Apply saturation + pil_image = ImageEnhance.Color(pil_image).enhance(saturation) + + if sharpness > 1.0 or sharpness < 1.0: + # Assign or create PIL Image + pil_image = pil_image if pil_image else tensor2pil(image) + # Apply sharpness + pil_image = ImageEnhance.Sharpness(pil_image).enhance(sharpness) + + if blur > 0: + # Assign or create PIL Image + pil_image = pil_image if pil_image else tensor2pil(image) + # Apply blur + for _ in range(blur): + pil_image = pil_image.filter(ImageFilter.BLUR) + + if gaussian_blur > 0.0: + # Assign or create PIL Image + pil_image = pil_image if pil_image else tensor2pil(image) + # Apply Gaussian blur + pil_image = pil_image.filter( + ImageFilter.GaussianBlur(radius=gaussian_blur)) + + if edge_enhance > 0.0: + # Assign or create PIL Image + pil_image = pil_image if pil_image else tensor2pil(image) + # Edge Enhancement + edge_enhanced_img = pil_image.filter(ImageFilter.EDGE_ENHANCE_MORE) + # Blend Mask + blend_mask = Image.new( + mode="L", size=pil_image.size, color=(round(edge_enhance * 255))) + # Composite Original and Enhanced Version + pil_image = Image.composite( + edge_enhanced_img, pil_image, blend_mask) + # Clean-up + del blend_mask, edge_enhanced_img + + # Output image + out_image = (pil2tensor(pil_image) if pil_image else image) + + return (out_image, ) + + +# IMAGE STYLE FILTER + +class WAS_Image_Style_Filter: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "style": ([ + "1977", + "aden", + "brannan", + "brooklyn", + "clarendon", + "earlybird", + "fairy tale", + "gingham", + "hudson", + "inkwell", + "kelvin", + "lark", + "lofi", + "maven", + "mayfair", + "moon", + "nashville", + "perpetua", + "reyes", + "rise", + "sci-fi", + "slumber", + "stinson", + "toaster", + "valencia", + "walden", + "willow", + "xpro2" + ],), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_style_filter" + + CATEGORY = "WAS Suite/Image" + + def image_style_filter(self, image, style): + + # Install Pilgram + if 'pilgram' not in packages(): + print("\033[34mWAS NS:\033[0m Installing Pilgram...") + subprocess.check_call( + [sys.executable, '-m', 'pip', '-q', 'install', 'pilgram']) + + # Import Pilgram module + import pilgram + + # Convert image to PIL + image = tensor2pil(image) + + # WAS Filters + WFilter = WAS_Filter_Class() + + # Apply blending + match style: + case "1977": + out_image = pilgram._1977(image) + case "aden": + out_image = pilgram.aden(image) + case "brannan": + out_image = pilgram.brannan(image) + case "brooklyn": + out_image = pilgram.brooklyn(image) + case "clarendon": + out_image = pilgram.clarendon(image) + case "earlybird": + out_image = pilgram.earlybird(image) + case "fairy tale": + out_image = WFilter.sparkle(image) + case "gingham": + out_image = pilgram.gingham(image) + case "hudson": + out_image = pilgram.hudson(image) + case "inkwell": + out_image = pilgram.inkwell(image) + case "kelvin": + out_image = pilgram.kelvin(image) + case "lark": + out_image = pilgram.lark(image) + case "lofi": + out_image = pilgram.lofi(image) + case "maven": + out_image = pilgram.maven(image) + case "mayfair": + out_image = pilgram.mayfair(image) + case "moon": + out_image = pilgram.moon(image) + case "nashville": + out_image = pilgram.nashville(image) + case "perpetua": + out_image = pilgram.perpetua(image) + case "reyes": + out_image = pilgram.reyes(image) + case "rise": + out_image = pilgram.rise(image) + case "slumber": + out_image = pilgram.slumber(image) + case "stinson": + out_image = pilgram.stinson(image) + case "toaster": + out_image = pilgram.toaster(image) + case "valencia": + out_image = pilgram.valencia(image) + case "walden": + out_image = pilgram.walden(image) + case "willow": + out_image = pilgram.willow(image) + case "xpro2": + out_image = pilgram.xpro2(image) + case _: + out_image = image + + out_image = out_image.convert("RGB") + + return (torch.from_numpy(np.array(out_image).astype(np.float32) / 255.0).unsqueeze(0), ) + + + +# COMBINE NODE + +class WAS_Image_Blending_Mode: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image_a": ("IMAGE",), + "image_b": ("IMAGE",), + "mode": ([ + "add", + "color", + "color_burn", + "color_dodge", + "darken", + "difference", + "exclusion", + "hard_light", + "hue", + "lighten", + "multiply", + "overlay", + "screen", + "soft_light" + ],), + "blend_percentage": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_blending_mode" + + CATEGORY = "WAS Suite/Image" + + def image_blending_mode(self, image_a, image_b, mode='add', blend_percentage=1.0): + + # Install Pilgram + if 'pilgram' not in packages(): + print("\033[34mWAS NS:\033[0m Installing Pilgram...") + subprocess.check_call( + [sys.executable, '-m', 'pip', '-q', 'install', 'pilgram']) + + # Import Pilgram module + import pilgram + + # Convert images to PIL + img_a = tensor2pil(image_a) + img_b = tensor2pil(image_b) + + # Apply blending + match mode: + case "color": + out_image = pilgram.css.blending.color(img_a, img_b) + case "color_burn": + out_image = pilgram.css.blending.color_burn(img_a, img_b) + case "color_dodge": + out_image = pilgram.css.blending.color_dodge(img_a, img_b) + case "darken": + out_image = pilgram.css.blending.darken(img_a, img_b) + case "difference": + out_image = pilgram.css.blending.difference(img_a, img_b) + case "exclusion": + out_image = pilgram.css.blending.exclusion(img_a, img_b) + case "hard_light": + out_image = pilgram.css.blending.hard_light(img_a, img_b) + case "hue": + out_image = pilgram.css.blending.hue(img_a, img_b) + case "lighten": + out_image = pilgram.css.blending.lighten(img_a, img_b) + case "multiply": + out_image = pilgram.css.blending.multiply(img_a, img_b) + case "add": + out_image = pilgram.css.blending.normal(img_a, img_b) + case "overlay": + out_image = pilgram.css.blending.overlay(img_a, img_b) + case "screen": + out_image = pilgram.css.blending.screen(img_a, img_b) + case "soft_light": + out_image = pilgram.css.blending.soft_light(img_a, img_b) + case _: + out_image = img_a + + out_image = out_image.convert("RGB") + + # Blend image + blend_mask = Image.new(mode="L", size=img_a.size, + color=(round(blend_percentage * 255))) + blend_mask = ImageOps.invert(blend_mask) + out_image = Image.composite(img_a, out_image, blend_mask) + + return (pil2tensor(out_image), ) + + +# IMAGE BLEND NODE + +class WAS_Image_Blend: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image_a": ("IMAGE",), + "image_b": ("IMAGE",), + "blend_percentage": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_blend" + + CATEGORY = "WAS Suite/Image" + + def image_blend(self, image_a, image_b, blend_percentage): + + # Convert images to PIL + img_a = tensor2pil(image_a) + img_b = tensor2pil(image_b) + + # Blend image + blend_mask = Image.new(mode="L", size=img_a.size, + color=(round(blend_percentage * 255))) + blend_mask = ImageOps.invert(blend_mask) + img_result = Image.composite(img_a, img_b, blend_mask) + + del img_a, img_b, blend_mask + + return (pil2tensor(img_result), ) + + + +# IMAGE MONITOR DISTORTION FILTER + +class WAS_Image_Monitor_Distortion_Filter: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "mode": (["Digital Distortion", "Signal Distortion", "TV Distortion"],), + "amplitude": ("INT", {"default": 5, "min": 1, "max": 255, "step": 1}), + "offset": ("INT", {"default": 10, "min": 1, "max": 255, "step": 1}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_monitor_filters" + + CATEGORY = "WAS Suite/Image" + + def image_monitor_filters(self, image, mode="Digital Distortion", amplitude=5, offset=5): + + # Convert images to PIL + image = tensor2pil(image) + + # WAS Filters + WFilter = WAS_Filter_Class() + + # Apply image effect + match mode: + case 'Digital Distortion': + image = WFilter.digital_distortion(image, amplitude, offset) + case 'Signal Distortion': + image = WFilter.signal_distortion(image, amplitude) + case 'TV Distortion': + image = WFilter.tv_vhs_distortion(image, amplitude) + + return (pil2tensor(image), ) + + +# IMAGE GENERATE COLOR PALETTE + +class WAS_Image_Color_Palette: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "colors": ("INT", {"default": 16, "min": 8, "max": 256, "step": 1}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_generate_palette" + + CATEGORY = "WAS Suite/Image" + + def image_generate_palette(self, image, colors=16): + + # Convert images to PIL + image = tensor2pil(image) + + # WAS Filters + WFilter = WAS_Filter_Class() + + res_dir = os.path.join(WAS_SUITE_ROOT, 'res') + font = os.path.join(res_dir, 'font.ttf') + + if not os.path.exists(font): + font = None + else: + print(f'\033[34mWAS NS:\033[0m Found font at `{font}`') + + # Generate Color Palette + image = WFilter.generate_palette(image, colors, 128, 10, font, 15) + + return (pil2tensor(image), ) + + + +# IMAGE ANALYZE + +class WAS_Image_Analyze: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "mode": (["Black White Levels", "RGB Levels"],), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_analyze" + + CATEGORY = "WAS Suite/Image" + + def image_analyze(self, image, mode='Black White Levels'): + + # Convert images to PIL + image = tensor2pil(image) + + # WAS Filters + WFilter = WAS_Filter_Class() + + # Analye Image + match mode: + case 'Black White Levels': + image = WFilter.black_white_levels(image) + case 'RGB Levels': + image = WFilter.channel_frequency(image) + + return (pil2tensor(image), ) + + +# IMAGE GENERATE GRADIENT + +class WAS_Image_Generate_Gradient: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + gradient_stops = '''0:255,0,0 +25:255,255,255 +50:0,255,0 +75:0,0,255''' + return { + "required": { + "width": ("INT", {"default":512, "max": 4096, "min": 64, "step":1}), + "height": ("INT", {"default":512, "max": 4096, "min": 64, "step":1}), + "direction": (["horizontal", "vertical"],), + "tolerance": ("INT", {"default":0, "max": 255, "min": 0, "step":1}), + "gradient_stops": ("STRING", {"default": gradient_stops, "multiline": True}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_gradient" + + CATEGORY = "WAS Suite/Image" + + def image_gradient(self, gradient_stops, width=512, height=512, direction='horizontal', tolerance=0): + + import io + + # WAS Filters + WFilter = WAS_Filter_Class() + + colors_dict = {} + stops = io.StringIO(gradient_stops.strip().replace(' ','')) + for stop in stops: + parts = stop.split(':') + colors = parts[1].replace('\n','').split(',') + colors_dict[parts[0].replace('\n','')] = colors + + image = WFilter.gradient((width, height), direction, colors_dict, tolerance) + + return (pil2tensor(image), ) + +# IMAGE GRADIENT MAP + +class WAS_Image_Gradient_Map: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "gradient_image": ("IMAGE",), + "flip_left_right": (["false", "true"],), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_gradient_map" + + CATEGORY = "WAS Suite/Image" + + def image_gradient_map(self, image, gradient_image, flip_left_right='false'): + + # Convert images to PIL + image = tensor2pil(image) + gradient_image = tensor2pil(gradient_image) + + # WAS Filters + WFilter = WAS_Filter_Class() + + image = WFilter.gradient_map(image, gradient_image, (True if flip_left_right == 'true' else False)) + + return (pil2tensor(image), ) + + +# IMAGE TRANSPOSE + +class WAS_Image_Transpose: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "image_overlay": ("IMAGE",), + "width": ("INT", {"default": 512, "min": -48000, "max": 48000, "step": 1}), + "height": ("INT", {"default": 512, "min": -48000, "max": 48000, "step": 1}), + "X": ("INT", {"default": 0, "min": -48000, "max": 48000, "step": 1}), + "Y": ("INT", {"default": 0, "min": -48000, "max": 48000, "step": 1}), + "rotation": ("INT", {"default": 0, "min": -360, "max": 360, "step": 1}), + "feathering": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_transpose" + + CATEGORY = "WAS Suite/Image" + + def image_transpose(self, image: torch.Tensor, image_overlay: torch.Tensor, width: int, height: int, X: int, Y: int, rotation: int, feathering: int = 0): + return (pil2tensor(self.apply_transpose_image(tensor2pil(image), tensor2pil(image_overlay), (width, height), (X, Y), rotation, feathering)), ) + + def apply_transpose_image(self, image_bg, image_element, size, loc, rotate=0, feathering=0): + + # Apply transformations to the element image + image_element = image_element.rotate(rotate, expand=True) + image_element = image_element.resize(size) + + # Create a mask for the image with the faded border + if feathering > 0: + mask = Image.new('L', image_element.size, 255) # Initialize with 255 instead of 0 + draw = ImageDraw.Draw(mask) + for i in range(feathering): + alpha_value = int(255 * (i + 1) / feathering) # Invert the calculation for alpha value + draw.rectangle((i, i, image_element.size[0] - i, image_element.size[1] - i), fill=alpha_value) + alpha_mask = Image.merge('RGBA', (mask, mask, mask, mask)) + image_element = Image.composite(image_element, Image.new('RGBA', image_element.size, (0, 0, 0, 0)), alpha_mask) + + # Create a new image of the same size as the base image with an alpha channel + new_image = Image.new('RGBA', image_bg.size, (0, 0, 0, 0)) + new_image.paste(image_element, loc) + + # Paste the new image onto the base image + image_bg = image_bg.convert('RGBA') + image_bg.paste(new_image, (0, 0), new_image) + + return image_bg + + + +# IMAGE RESCALE + +class WAS_Image_Rescale: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "mode": (["rescale", "resize"],), + "supersample": (["true", "false"],), + "resampling": (["lanczos", "nearest", "bilinear", "bicubic"],), + "rescale_factor": ("FLOAT", {"default": 2, "min": 0.01, "max": 16.0, "step": 0.01}), + "resize_width": ("INT", {"default": 1024, "min": 1, "max": 48000, "step": 1}), + "resize_height": ("INT", {"default": 1536, "min": 1, "max": 48000, "step": 1}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_rescale" + + CATEGORY = "WAS Suite/Image" + + def image_rescale(self, image: torch.Tensor, mode="rescale", supersample='true', resampling="lanczos", rescale_factor=2, resize_width=1024, resize_height=1024): + return (pil2tensor(self.apply_resize_image(tensor2pil(image), mode, supersample, rescale_factor, resize_width, resize_height, resampling)), ) + + def apply_resize_image(self, image: Image.Image, mode='scale', supersample='true', factor: int = 2, width: int = 1024, height: int = 1024, resample='bicubic'): + + # Get the current width and height of the image + current_width, current_height = image.size + + # Calculate the new width and height based on the given mode and parameters + if mode == 'rescale': + new_width, new_height = int( + current_width * factor), int(current_height * factor) + else: + new_width = width if width % 8 == 0 else width + (8 - width % 8) + new_height = height if height % 8 == 0 else height + \ + (8 - height % 8) + + # Define a dictionary of resampling filters + resample_filters = { + 'nearest': 0, + 'bilinear': 2, + 'bicubic': 3, + 'lanczos': 1 + } + + # Apply supersample + if supersample == 'true': + image = image.resize((new_width * 8, new_height * 8), resample=Image.Resampling(resample_filters[resample])) + + # Resize the image using the given resampling filter + resized_image = image.resize((new_width, new_height), resample=Image.Resampling(resample_filters[resample])) + + return resized_image + + +# LOAD IMAGE BATCH + +class WAS_Load_Image_Batch: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mode": (["single_image", "incremental_image"],), + "index": ("INT", {"default": 0, "min": 0, "max": 150000, "step": 1}), + "label": ("STRING", {"default": 'Batch 001', "multiline": False}), + "path": ("STRING", {"default": './ComfyUI/input/', "multiline": False}), + "pattern": ("STRING", {"default": '*', "multiline": False}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "load_batch_images" + + CATEGORY = "WAS Suite/IO" + + def load_batch_images(self, path, pattern='*', index=0, mode="single_image", label='Batch 001') -> tuple[torch.Tensor] | tuple: + + if not os.path.exists(path): + return (None, ) + fl = self.BatchImageLoader(path, label, pattern) + if mode == 'single_image': + image = fl.get_image_by_id(index) + else: + image = fl.get_next_image() + self.image = image + + return (pil2tensor(image), ) + + class BatchImageLoader: + def __init__(self, directory_path, label, pattern): + self.WDB = WDB + self.image_paths = [] + self.load_images(directory_path, pattern) + self.image_paths.sort() # sort the image paths by name + stored_directory_path = self.WDB.get('Batch Paths', label) + stored_pattern = self.WDB.get('Batch Patterns', label) + if stored_directory_path != directory_path or stored_pattern != pattern: + self.index = 0 + self.WDB.insert('Batch Counters', label, 0) + self.WDB.insert('Batch Paths', label, directory_path) + self.WDB.insert('Batch Patterns', label, pattern) + else: + self.index = self.WDB.get('Batch Counters', label) + self.label = label + + def load_images(self, directory_path, pattern): + allowed_extensions = ('.jpeg', '.jpg', '.png', + '.tiff', '.gif', '.bmp', '.webp') + for file_name in glob.glob(os.path.join(directory_path, pattern), recursive=True): + if file_name.lower().endswith(allowed_extensions): + image_path = os.path.join(directory_path, file_name) + self.image_paths.append(image_path) + + def get_image_by_id(self, image_id): + if image_id < 0 or image_id >= len(self.image_paths): + raise ValueError(f"\033[34mWAS NS\033[0m Error: Invalid image index `{image_id}`") + return Image.open(self.image_paths[image_id]) + + def get_next_image(self): + if self.index >= len(self.image_paths): + self.index = 0 + image_path = self.image_paths[self.index] + self.index += 1 + if self.index == len(self.image_paths): + self.index = 0 + print(f'\033[34mWAS NS \033[33m{self.label}\033[0m Index:', self.index) + self.WDB.insert('Batch Counters', self.label, self.index) + return Image.open(image_path) + + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("NaN") + + +# IMAGE PADDING + +class WAS_Image_Padding: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "feathering": ("INT", {"default": 120, "min": 0, "max": 2048, "step": 1}), + "feather_second_pass": (["true", "false"],), + "left_padding": ("INT", {"default": 512, "min": 8, "max": 48000, "step": 1}), + "right_padding": ("INT", {"default": 512, "min": 8, "max": 48000, "step": 1}), + "top_padding": ("INT", {"default": 512, "min": 8, "max": 48000, "step": 1}), + "bottom_padding": ("INT", {"default": 512, "min": 8, "max": 48000, "step": 1}), + }, + } + + RETURN_TYPES = ("IMAGE", "IMAGE") + FUNCTION = "image_padding" + + CATEGORY = "WAS Suite/Image" + + def image_padding(self, image, feathering, left_padding, right_padding, top_padding, bottom_padding, feather_second_pass=True): + padding = self.apply_image_padding(tensor2pil( + image), left_padding, right_padding, top_padding, bottom_padding, feathering, second_pass=True) + return (pil2tensor(padding[0]), pil2tensor(padding[1])) + + def apply_image_padding(self, image, left_pad=100, right_pad=100, top_pad=100, bottom_pad=100, feather_radius=50, second_pass=True): + # Create a mask for the feathered edge + mask = Image.new('L', image.size, 255) + draw = ImageDraw.Draw(mask) + + # Draw black rectangles at each edge of the image with the specified feather radius + draw.rectangle((0, 0, feather_radius*2, image.height), fill=0) + draw.rectangle((image.width-feather_radius*2, 0, + image.width, image.height), fill=0) + draw.rectangle((0, 0, image.width, feather_radius*2), fill=0) + draw.rectangle((0, image.height-feather_radius*2, + image.width, image.height), fill=0) + + # Blur the mask to create a smooth gradient between the black shapes and the white background + mask = mask.filter(ImageFilter.GaussianBlur(radius=feather_radius)) + + # Apply mask if second_pass is False, apply both masks if second_pass is True + if second_pass: + + # Create a second mask for the additional feathering pass + mask2 = Image.new('L', image.size, 255) + draw2 = ImageDraw.Draw(mask2) + + # Draw black rectangles at each edge of the image with a smaller feather radius + feather_radius2 = int(feather_radius / 4) + draw2.rectangle((0, 0, feather_radius2*2, image.height), fill=0) + draw2.rectangle((image.width-feather_radius2*2, 0, + image.width, image.height), fill=0) + draw2.rectangle((0, 0, image.width, feather_radius2*2), fill=0) + draw2.rectangle((0, image.height-feather_radius2*2, + image.width, image.height), fill=0) + + # Blur the mask to create a smooth gradient between the black shapes and the white background + mask2 = mask2.filter( + ImageFilter.GaussianBlur(radius=feather_radius2)) + + feathered_im = Image.new('RGBA', image.size, (0, 0, 0, 0)) + feathered_im.paste(image, (0, 0), mask) + feathered_im.paste(image, (0, 0), mask) + + # Apply the second mask to the feathered image + feathered_im.paste(image, (0, 0), mask2) + feathered_im.paste(image, (0, 0), mask2) + + else: + + # Apply the fist maskk + feathered_im = Image.new('RGBA', image.size, (0, 0, 0, 0)) + feathered_im.paste(image, (0, 0), mask) + + # Calculate the new size of the image with padding added + new_size = (feathered_im.width + left_pad + right_pad, + feathered_im.height + top_pad + bottom_pad) + + # Create a new transparent image with the new size + new_im = Image.new('RGBA', new_size, (0, 0, 0, 0)) + + # Paste the feathered image onto the new image with the padding + new_im.paste(feathered_im, (left_pad, top_pad)) + + # Create Padding Mask + padding_mask = Image.new('L', new_size, 0) + + # Create a mask where the transparent pixels have a gradient + gradient = [(int(255 * (1 - p[3] / 255)) if p[3] != 0 else 255) + for p in new_im.getdata()] + padding_mask.putdata(gradient) + + # Save the new image with alpha channel as a PNG file + return (new_im, padding_mask.convert('RGB')) + + +# IMAGE THRESHOLD NODE + +class WAS_Image_Threshold: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_threshold" + + CATEGORY = "WAS Suite/Image" + + def image_threshold(self, image, threshold=0.5): + return (pil2tensor(self.apply_threshold(tensor2pil(image), threshold)), ) + + def apply_threshold(self, input_image, threshold=0.5): + # Convert the input image to grayscale + grayscale_image = input_image.convert('L') + + # Apply the threshold to the grayscale image + threshold_value = int(threshold * 255) + thresholded_image = grayscale_image.point( + lambda x: 255 if x >= threshold_value else 0, mode='L') + + return thresholded_image + + +# IMAGE CHROMATIC ABERRATION NODE + +class WAS_Image_Chromatic_Aberration: + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "red_offset": ("INT", {"default": 2, "min": -255, "max": 255, "step": 1}), + "green_offset": ("INT", {"default": -1, "min": -255, "max": 255, "step": 1}), + "blue_offset": ("INT", {"default": 1, "min": -255, "max": 255, "step": 1}), + "intensity": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_chromatic_aberration" + + CATEGORY = "WAS Suite/Image" + + def image_chromatic_aberration(self, image, red_offset=4, green_offset=2, blue_offset=0, intensity=1): + return (pil2tensor(self.apply_chromatic_aberration(tensor2pil(image), red_offset, green_offset, blue_offset, intensity)), ) + + def apply_chromatic_aberration(self, img, r_offset, g_offset, b_offset, intensity): + # split the channels of the image + r, g, b = img.split() + + # apply the offset to each channel + r_offset_img = ImageChops.offset(r, r_offset, 0) + g_offset_img = ImageChops.offset(g, 0, g_offset) + b_offset_img = ImageChops.offset(b, 0, b_offset) + + # blend the original image with the offset channels + blended_r = ImageChops.blend(r, r_offset_img, intensity) + blended_g = ImageChops.blend(g, g_offset_img, intensity) + blended_b = ImageChops.blend(b, b_offset_img, intensity) + + # merge the channels back into an RGB image + result = Image.merge("RGB", (blended_r, blended_g, blended_b)) + + return result + + +# IMAGE BLOOM FILTER + +class WAS_Image_Bloom_Filter: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "radius": ("FLOAT", {"default": 10, "min": 0.0, "max": 1024, "step": 0.1}), + "intensity": ("FLOAT", {"default": 1, "min": 0.0, "max": 1.0, "step": 0.1}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_bloom" + + CATEGORY = "WAS Suite/Image" + + def image_bloom(self, image, radius=0.5, intensity=1.0): + return (pil2tensor(self.apply_bloom_filter(tensor2pil(image), radius, intensity)), ) + + def apply_bloom_filter(self, input_image, radius, bloom_factor): + # Apply a blur filter to the input image + blurred_image = input_image.filter( + ImageFilter.GaussianBlur(radius=radius)) + + # Subtract the blurred image from the input image to create a high-pass filter + high_pass_filter = ImageChops.subtract(input_image, blurred_image) + + # Create a blurred version of the bloom filter + bloom_filter = high_pass_filter.filter( + ImageFilter.GaussianBlur(radius=radius*2)) + + # Adjust brightness and levels of bloom filter + bloom_filter = ImageEnhance.Brightness(bloom_filter).enhance(2.0) + + # Multiply the bloom image with the bloom factor + bloom_filter = ImageChops.multiply(bloom_filter, Image.new('RGB', input_image.size, (int( + 255 * bloom_factor), int(255 * bloom_factor), int(255 * bloom_factor)))) + + # Multiply the bloom filter with the original image using the bloom factor + blended_image = ImageChops.screen(input_image, bloom_filter) + + return blended_image + + +# IMAGE REMOVE COLOR + +class WAS_Image_Remove_Color: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "target_red": ("INT", {"default": 255, "min": 0, "max": 255, "step": 1}), + "target_green": ("INT", {"default": 255, "min": 0, "max": 255, "step": 1}), + "target_blue": ("INT", {"default": 255, "min": 0, "max": 255, "step": 1}), + "replace_red": ("INT", {"default": 255, "min": 0, "max": 255, "step": 1}), + "replace_green": ("INT", {"default": 255, "min": 0, "max": 255, "step": 1}), + "replace_blue": ("INT", {"default": 255, "min": 0, "max": 255, "step": 1}), + "clip_threshold": ("INT", {"default": 10, "min": 0, "max": 255, "step": 1}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_remove_color" + + CATEGORY = "WAS Suite/Image" + + def image_remove_color(self, image, clip_threshold=10, target_red=255, target_green=255, target_blue=255, replace_red=255, replace_green=255, replace_blue=255): + return (pil2tensor(self.apply_remove_color(tensor2pil(image), clip_threshold, (target_red, target_green, target_blue), (replace_red, replace_green, replace_blue))), ) + + def apply_remove_color(self, image, threshold=10, color=(255, 255, 255), rep_color=(0, 0, 0)): + # Create a color image with the same size as the input image + color_image = Image.new('RGB', image.size, color) + + # Calculate the difference between the input image and the color image + diff_image = ImageChops.difference(image, color_image) + + # Convert the difference image to grayscale + gray_image = diff_image.convert('L') + + # Apply a threshold to the grayscale difference image + mask_image = gray_image.point(lambda x: 255 if x > threshold else 0) + + # Invert the mask image + mask_image = ImageOps.invert(mask_image) + + # Apply the mask to the original image + result_image = Image.composite( + Image.new('RGB', image.size, rep_color), image, mask_image) + + return result_image + + +# IMAGE REMOVE BACKGROUND + +class WAS_Remove_Background: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "mode": (["background", "foreground"],), + "threshold": ("INT", {"default": 127, "min": 0, "max": 255, "step": 1}), + "threshold_tolerance": ("INT", {"default": 2, "min": 1, "max": 24, "step": 1}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_remove_background" + + CATEGORY = "WAS Suite/Image" + + def image_remove_background(self, image, mode='background', threshold=127, threshold_tolerance=2): + return (pil2tensor(self.remove_background(tensor2pil(image), mode, threshold, threshold_tolerance)), ) + + def remove_background(self, image, mode, threshold, threshold_tolerance): + grayscale_image = image.convert('L') + if mode == 'background': + grayscale_image = ImageOps.invert(grayscale_image) + threshold = 255 - threshold # adjust the threshold for "background" mode + blurred_image = grayscale_image.filter( + ImageFilter.GaussianBlur(radius=threshold_tolerance)) + binary_image = blurred_image.point( + lambda x: 0 if x < threshold else 255, '1') + mask = binary_image.convert('L') + inverted_mask = ImageOps.invert(mask) + transparent_image = image.copy() + transparent_image.putalpha(inverted_mask) + + return transparent_image + + +# IMAGE BLEND MASK NODE + +class WAS_Image_Blend_Mask: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image_a": ("IMAGE",), + "image_b": ("IMAGE",), + "mask": ("IMAGE",), + "blend_percentage": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_blend_mask" + + CATEGORY = "WAS Suite/Image" + + def image_blend_mask(self, image_a, image_b, mask, blend_percentage): + + # Convert images to PIL + img_a = tensor2pil(image_a) + img_b = tensor2pil(image_b) + mask = ImageOps.invert(tensor2pil(mask).convert('L')) + + # Mask image + masked_img = Image.composite(img_a, img_b, mask.resize(img_a.size)) + + # Blend image + blend_mask = Image.new(mode="L", size=img_a.size, + color=(round(blend_percentage * 255))) + blend_mask = ImageOps.invert(blend_mask) + img_result = Image.composite(img_a, masked_img, blend_mask) + + del img_a, img_b, blend_mask, mask + + return (pil2tensor(img_result), ) + + +# IMAGE BLANK NOE + + +class WAS_Image_Blank: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "width": ("INT", {"default": 512, "min": 8, "max": 4096, "step": 1}), + "height": ("INT", {"default": 512, "min": 8, "max": 4096, "step": 1}), + "red": ("INT", {"default": 255, "min": 0, "max": 255, "step": 1}), + "green": ("INT", {"default": 255, "min": 0, "max": 255, "step": 1}), + "blue": ("INT", {"default": 255, "min": 0, "max": 255, "step": 1}), + } + } + RETURN_TYPES = ("IMAGE",) + FUNCTION = "blank_image" + + CATEGORY = "WAS Suite/Image" + + def blank_image(self, width, height, red, green, blue): + + # Ensure multiples + width = (width // 8) * 8 + height = (height // 8) * 8 + + # Blend image + blank = Image.new(mode="RGB", size=(width, height), + color=(red, green, blue)) + + return (pil2tensor(blank), ) + + +# IMAGE HIGH PASS + +class WAS_Image_High_Pass_Filter: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "radius": ("INT", {"default": 10, "min": 1, "max": 500, "step": 1}), + "strength": ("FLOAT", {"default": 1.5, "min": 0.0, "max": 255.0, "step": 0.1}) + } + } + RETURN_TYPES = ("IMAGE",) + FUNCTION = "high_pass" + + CATEGORY = "WAS Suite/Image" + + def high_pass(self, image, radius=10, strength=1.5): + hpf = tensor2pil(image).convert('L') + return (pil2tensor(self.apply_hpf(hpf.convert('RGB'), radius, strength)), ) + + def apply_hpf(self, img, radius=10, strength=1.5): + + # pil to numpy + img_arr = np.array(img).astype('float') + + # Apply a Gaussian blur with the given radius + blurred_arr = np.array(img.filter( + ImageFilter.GaussianBlur(radius=radius))).astype('float') + + # Apply the High Pass Filter + hpf_arr = img_arr - blurred_arr + hpf_arr = np.clip(hpf_arr * strength, 0, 255).astype('uint8') + + # Convert the numpy array back to a PIL image and return it + return Image.fromarray(hpf_arr, mode='RGB') + + +# IMAGE LEVELS NODE + +class WAS_Image_Levels: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "black_level": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 255.0, "step": 0.1}), + "mid_level": ("FLOAT", {"default": 127.5, "min": 0.0, "max": 255.0, "step": 0.1}), + "white_level": ("FLOAT", {"default": 255, "min": 0.0, "max": 255.0, "step": 0.1}), + } + } + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_image_levels" + + CATEGORY = "WAS Suite/Image" + + def apply_image_levels(self, image, black_level, mid_level, white_level): + + # Convert image to PIL + image = tensor2pil(image) + + # apply image levels + # image = self.adjust_levels(image, black_level, mid_level, white_level) + + levels = self.AdjustLevels(black_level, mid_level, white_level) + image = levels.adjust(image) + + # Return adjust image tensor + return (pil2tensor(image), ) + + def adjust_levels(self, image, black=0.0, mid=1.0, white=255): + """ + Adjust the black, mid, and white levels of an RGB image. + """ + # Create a new empty image with the same size and mode as the original image + result = Image.new(image.mode, image.size) + + # Check that the mid value is within the valid range + if mid < 0 or mid > 1: + raise ValueError("mid value must be between 0 and 1") + + # Create a lookup table to map the pixel values to new values + lut = [] + for i in range(256): + if i < black: + lut.append(0) + elif i > white: + lut.append(255) + else: + lut.append(int(((i - black) / (white - black)) ** mid * 255.0)) + + # Split the image into its red, green, and blue channels + r, g, b = image.split() + + # Apply the lookup table to each channel + r = r.point(lut) + g = g.point(lut) + b = b.point(lut) + + # Merge the channels back into an RGB image + result = Image.merge("RGB", (r, g, b)) + + return result + + class AdjustLevels: + def __init__(self, min_level, mid_level, max_level): + self.min_level = min_level + self.mid_level = mid_level + self.max_level = max_level + + def adjust(self, im): + # load the image + + # convert the image to a numpy array + im_arr = np.array(im) + + # apply the min level adjustment + im_arr[im_arr < self.min_level] = self.min_level + + # apply the mid level adjustment + im_arr = (im_arr - self.min_level) * \ + (255 / (self.max_level - self.min_level)) + im_arr[im_arr < 0] = 0 + im_arr[im_arr > 255] = 255 + im_arr = im_arr.astype(np.uint8) + + # apply the max level adjustment + im = Image.fromarray(im_arr) + im = ImageOps.autocontrast(im, cutoff=self.max_level) + + return im + + +# FILM GRAIN NODE + +class WAS_Film_Grain: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "density": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 1.0, "step": 0.01}), + "intensity": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 1.0, "step": 0.01}), + "highlights": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 255.0, "step": 0.01}), + "supersample_factor": ("INT", {"default": 4, "min": 1, "max": 8, "step": 1}) + } + } + RETURN_TYPES = ("IMAGE",) + FUNCTION = "film_grain" + + CATEGORY = "WAS Suite/Image" + + def film_grain(self, image, density, intensity, highlights, supersample_factor): + return (pil2tensor(self.apply_film_grain(tensor2pil(image), density, intensity, highlights, supersample_factor)), ) + + def apply_film_grain(self, img, density=0.1, intensity=1.0, highlights=1.0, supersample_factor=4): + """ + Apply grayscale noise with specified density, intensity, and highlights to a PIL image. + """ + # Convert the image to grayscale + img_gray = img.convert('L') + + # Super Resolution noise image + original_size = img.size + img_gray = img_gray.resize( + ((img.size[0] * supersample_factor), (img.size[1] * supersample_factor)), Image.Resampling(2)) + + # Calculate the number of noise pixels to add + num_pixels = int(density * img_gray.size[0] * img_gray.size[1]) + + # Create a list of noise pixel positions + noise_pixels = [] + for i in range(num_pixels): + x = random.randint(0, img_gray.size[0]-1) + y = random.randint(0, img_gray.size[1]-1) + noise_pixels.append((x, y)) + + # Apply the noise to the grayscale image + for x, y in noise_pixels: + value = random.randint(0, 255) + img_gray.putpixel((x, y), value) + + # Convert the grayscale image back to RGB + img_noise = img_gray.convert('RGB') + + # Blur noise image + img_noise = img_noise.filter(ImageFilter.GaussianBlur(radius=0.125)) + + # Downsize noise image + img_noise = img_noise.resize(original_size, Image.Resampling(1)) + + # Sharpen super resolution result + img_noise = img_noise.filter(ImageFilter.EDGE_ENHANCE_MORE) + + # Blend the noisy color image with the original color image + img_final = Image.blend(img, img_noise, intensity) + + # Adjust the highlights + enhancer = ImageEnhance.Brightness(img_final) + img_highlights = enhancer.enhance(highlights) + + # Return the final image + return img_highlights + + +# IMAGE FLIP NODE + +class WAS_Image_Flip: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "mode": (["horizontal", "vertical",],), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_flip" + + CATEGORY = "WAS Suite/Image" + + def image_flip(self, image, mode): + + # PIL Image + image = tensor2pil(image) + + # Rotate Image + if mode == 'horizontal': + image = image.transpose(0) + if mode == 'vertical': + image = image.transpose(1) + + return (pil2tensor(image), ) + + +class WAS_Image_Rotate: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "mode": (["transpose", "internal",],), + "rotation": ("INT", {"default": 0, "min": 0, "max": 360, "step": 90}), + "sampler": (["nearest", "bilinear", "bicubic"],), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_rotate" + + CATEGORY = "WAS Suite/Image" + + def image_rotate(self, image, mode, rotation, sampler): + + # PIL Image + image = tensor2pil(image) + + # Check rotation + if rotation > 360: + rotation = int(360) + if (rotation % 90 != 0): + rotation = int((rotation//90)*90) + + # Set Sampler + match sampler: + case 'nearest': + sampler = Image.NEAREST + case 'bicubic': + sampler = Image.BICUBIC + case 'bilinear': + sampler = Image.BILINEAR + + # Rotate Image + if mode == 'internal': + image = image.rotate(rotation, sampler) + else: + rot = int(rotation / 90) + for _ in range(rot): + image = image.transpose(2) + + return (torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0), ) + + +# IMAGE NOVA SINE FILTER + +class WAS_Image_Nova_Filter: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "amplitude": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.001}), + "frequency": ("FLOAT", {"default": 3.14, "min": 0.0, "max": 100.0, "step": 0.001}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "nova_sine" + + CATEGORY = "WAS Suite/Image" + + def nova_sine(self, image, amplitude, frequency): + + # Convert image to numpy + img = tensor2pil(image) + + # Convert the image to a numpy array + img_array = np.array(img) + + # Define a sine wave function + def sine(x, freq, amp): + return amp * np.sin(2 * np.pi * freq * x) + + # Calculate the sampling frequency of the image + resolution = img.info.get('dpi') # PPI + physical_size = img.size # pixels + + if resolution is not None: + # Convert PPI to pixels per millimeter (PPM) + ppm = 25.4 / resolution + physical_size = tuple(int(pix * ppm) for pix in physical_size) + + # Set the maximum frequency for the sine wave + max_freq = img.width / 2 + + # Ensure frequency isn't outside visual representable range + if frequency > max_freq: + frequency = max_freq + + # Apply levels to the image using the sine function + for i in range(img_array.shape[0]): + for j in range(img_array.shape[1]): + for k in range(img_array.shape[2]): + img_array[i, j, k] = int( + sine(img_array[i, j, k]/255, frequency, amplitude) * 255) + + return (torch.from_numpy(img_array.astype(np.float32) / 255.0).unsqueeze(0), ) + + +# IMAGE CANNY FILTER + + +class WAS_Canny_Filter: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "enable_threshold": (['false', 'true'],), + "threshold_low": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "threshold_high": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "canny_filter" + + CATEGORY = "WAS Suite/Image" + + def canny_filter(self, image, threshold_low, threshold_high, enable_threshold): + + self.install_opencv() + + if enable_threshold == 'false': + threshold_low = None + threshold_high = None + + image_canny = Image.fromarray(self.Canny_detector( + 255. * image.cpu().numpy().squeeze(), threshold_low, threshold_high)).convert('RGB') + + return (pil2tensor(image_canny), ) + + # Defining the Canny Detector function + # From: https://www.geeksforgeeks.org/implement-canny-edge-detector-in-python-using-opencv/ + + # here weak_th and strong_th are thresholds for + # double thresholding step + def Canny_detector(self, img, weak_th=None, strong_th=None): + + import cv2 + + # conversion of image to grayscale + img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + + # Noise reduction step + img = cv2.GaussianBlur(img, (5, 5), 1.4) + + # Calculating the gradients + gx = cv2.Sobel(np.float32(img), cv2.CV_64F, 1, 0, 3) # type: ignore + gy = cv2.Sobel(np.float32(img), cv2.CV_64F, 0, 1, 3) # type: ignore + + # Conversion of Cartesian coordinates to polar + mag, ang = cv2.cartToPolar(gx, gy, angleInDegrees=True) + + # setting the minimum and maximum thresholds + # for double thresholding + mag_max = np.max(mag) + if not weak_th: + weak_th = mag_max * 0.1 + if not strong_th: + strong_th = mag_max * 0.5 + + # getting the dimensions of the input image + height, width = img.shape + + # Looping through every pixel of the grayscale + # image + for i_x in range(width): + for i_y in range(height): + + grad_ang = ang[i_y, i_x] + grad_ang = abs( + grad_ang-180) if abs(grad_ang) > 180 else abs(grad_ang) + + neighb_1_x, neighb_1_y = -1, -1 + neighb_2_x, neighb_2_y = -1, -1 + + # selecting the neighbours of the target pixel + # according to the gradient direction + # In the x axis direction + if grad_ang <= 22.5: + neighb_1_x, neighb_1_y = i_x-1, i_y + neighb_2_x, neighb_2_y = i_x + 1, i_y + + # top right (diagonal-1) direction + elif grad_ang > 22.5 and grad_ang <= (22.5 + 45): + neighb_1_x, neighb_1_y = i_x-1, i_y-1 + neighb_2_x, neighb_2_y = i_x + 1, i_y + 1 + + # In y-axis direction + elif grad_ang > (22.5 + 45) and grad_ang <= (22.5 + 90): + neighb_1_x, neighb_1_y = i_x, i_y-1 + neighb_2_x, neighb_2_y = i_x, i_y + 1 + + # top left (diagonal-2) direction + elif grad_ang > (22.5 + 90) and grad_ang <= (22.5 + 135): + neighb_1_x, neighb_1_y = i_x-1, i_y + 1 + neighb_2_x, neighb_2_y = i_x + 1, i_y-1 + + # Now it restarts the cycle + elif grad_ang > (22.5 + 135) and grad_ang <= (22.5 + 180): + neighb_1_x, neighb_1_y = i_x-1, i_y + neighb_2_x, neighb_2_y = i_x + 1, i_y + + # Non-maximum suppression step + if width > neighb_1_x >= 0 and height > neighb_1_y >= 0: + if mag[i_y, i_x] < mag[neighb_1_y, neighb_1_x]: + mag[i_y, i_x] = 0 + continue + + if width > neighb_2_x >= 0 and height > neighb_2_y >= 0: + if mag[i_y, i_x] < mag[neighb_2_y, neighb_2_x]: + mag[i_y, i_x] = 0 + + weak_ids = np.zeros_like(img) + strong_ids = np.zeros_like(img) + ids = np.zeros_like(img) + + # double thresholding step + for i_x in range(width): + for i_y in range(height): + + grad_mag = mag[i_y, i_x] + + if grad_mag < weak_th: + mag[i_y, i_x] = 0 + elif strong_th > grad_mag >= weak_th: + ids[i_y, i_x] = 1 + else: + ids[i_y, i_x] = 2 + + # finally returning the magnitude of + # gradients of edges + return mag + + def install_opencv(self): + if 'opencv-python' not in packages(): + print("\033[34mWAS NS:\033[0m Installing CV2...") + subprocess.check_call([sys.executable, '-m', 'pip', '-q', 'install', 'opencv-python']) + + +# IMAGE EDGE DETECTION + +class WAS_Image_Edge: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "mode": (["normal", "laplacian"],), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "image_edges" + + CATEGORY = "WAS Suite/Image" + + def image_edges(self, image, mode): + + # Convert image to PIL + image = tensor2pil(image) + + # Detect edges + match mode: + case "normal": + image = image.filter(ImageFilter.FIND_EDGES) + case "laplacian": + image = image.filter(ImageFilter.Kernel((3, 3), (-1, -1, -1, -1, 8, + -1, -1, -1, -1), 1, 0)) + case _: + image = image + + return (torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0), ) + + +# IMAGE FDOF NODE + +class WAS_Image_fDOF: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "depth": ("IMAGE",), + "mode": (["mock", "gaussian", "box"],), + "radius": ("INT", {"default": 8, "min": 1, "max": 128, "step": 1}), + "samples": ("INT", {"default": 1, "min": 1, "max": 3, "step": 1}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "fdof_composite" + + CATEGORY = "WAS Suite/Image" + + def fdof_composite(self, image: torch.Tensor, depth: torch.Tensor, radius: int, samples: int, mode: str) -> tuple[torch.Tensor]: + + if 'opencv-python' not in packages(): + print("\033[34mWAS NS:\033[0m Installing CV2...") + subprocess.check_call( + [sys.executable, '-m', 'pip', '-q', 'install', 'opencv-python']) + + import cv2 as cv + + # Convert tensor to a PIL Image + i = 255. * image.cpu().numpy().squeeze() + img: Image.Image = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + d = 255. * depth.cpu().numpy().squeeze() + depth_img: Image.Image = Image.fromarray( + np.clip(d, 0, 255).astype(np.uint8)) + + # Apply Fake Depth of Field + fdof_image = self.portraitBlur(img, depth_img, radius, samples, mode) + + return (torch.from_numpy(np.array(fdof_image).astype(np.float32) / 255.0).unsqueeze(0), ) + + def portraitBlur(self, img: Image.Image, mask: Image.Image, radius: int = 5, samples: int = 1, mode='mock') -> Optional[Image.Image]: + mask = mask.resize(img.size).convert('L') + bimg: Optional[Image.Image] = None + if mode == 'mock': + bimg = medianFilter(img, radius, (radius * 1500), 75) + elif mode == 'gaussian': + bimg = img.filter(ImageFilter.GaussianBlur(radius=radius)) + elif mode == 'box': + bimg = img.filter(ImageFilter.BoxBlur(radius)) + else: + return + bimg.convert(img.mode) + rimg: Optional[Image.Image] = None + if samples > 1: + for i in range(samples): + if not rimg: + rimg = Image.composite(img, bimg, mask) + else: + rimg = Image.composite(rimg, bimg, mask) + else: + rimg = Image.composite(img, bimg, mask).convert('RGB') + + return rimg + + # TODO: Implement lens_blur mode attempt + def lens_blur(self, img, radius, amount, mask=None): + """Applies a lens shape blur effect on an image. + + Args: + img (numpy.ndarray): The input image as a numpy array. + radius (float): The radius of the lens shape. + amount (float): The amount of blur to be applied. + mask (numpy.ndarray): An optional mask image specifying where to apply the blur. + + Returns: + numpy.ndarray: The blurred image as a numpy array. + """ + # Create a lens shape kernel. + kernel = cv2.getGaussianKernel(ksize=int(radius * 10), sigma=0) + kernel = np.dot(kernel, kernel.T) + + # Normalize the kernel. + kernel /= np.max(kernel) + + # Create a circular mask for the kernel. + mask_shape = (int(radius * 2), int(radius * 2)) + mask = np.ones(mask_shape) if mask is None else cv2.resize( + mask, mask_shape, interpolation=cv2.INTER_LINEAR) + mask = cv2.GaussianBlur( + mask, (int(radius * 2) + 1, int(radius * 2) + 1), radius / 2) + mask /= np.max(mask) + + # Adjust kernel and mask size to match input image. + ksize_x = img.shape[1] // (kernel.shape[1] + 1) + ksize_y = img.shape[0] // (kernel.shape[0] + 1) + kernel = cv2.resize(kernel, (ksize_x, ksize_y), + interpolation=cv2.INTER_LINEAR) + kernel = cv2.copyMakeBorder( + kernel, 0, img.shape[0] - kernel.shape[0], 0, img.shape[1] - kernel.shape[1], cv2.BORDER_CONSTANT, value=0) + mask = cv2.resize(mask, (ksize_x, ksize_y), + interpolation=cv2.INTER_LINEAR) + mask = cv2.copyMakeBorder( + mask, 0, img.shape[0] - mask.shape[0], 0, img.shape[1] - mask.shape[1], cv2.BORDER_CONSTANT, value=0) + + # Apply the lens shape blur effect on the image. + blurred = cv2.filter2D(img, -1, kernel) + blurred = cv2.filter2D(blurred, -1, mask * amount) + + if mask is not None: + # Apply the mask to the original image. + mask = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR) + img_masked = img * mask + # Combine the masked image with the blurred image. + blurred = img_masked * (1 - mask) + blurred # type: ignore + + return blurred + + +# IMAGE MEDIAN FILTER NODE + +class WAS_Image_Median_Filter: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "diameter": ("INT", {"default": 2.0, "min": 0.1, "max": 255, "step": 1}), + "sigma_color": ("FLOAT", {"default": 10.0, "min": -255.0, "max": 255.0, "step": 0.1}), + "sigma_space": ("FLOAT", {"default": 10.0, "min": -255.0, "max": 255.0, "step": 0.1}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_median_filter" + + CATEGORY = "WAS Suite/Image" + + def apply_median_filter(self, image, diameter, sigma_color, sigma_space): + + # Numpy Image + image = tensor2pil(image) + + # Apply Median Filter effect + image = medianFilter(image, diameter, sigma_color, sigma_space) + + return (pil2tensor(image), ) + +# IMAGE SELECT COLOR + + +class WAS_Image_Select_Color: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "red": ("INT", {"default": 255.0, "min": 0.0, "max": 255.0, "step": 0.1}), + "green": ("INT", {"default": 255.0, "min": 0.0, "max": 255.0, "step": 0.1}), + "blue": ("INT", {"default": 255.0, "min": 0.0, "max": 255.0, "step": 0.1}), + "variance": ("INT", {"default": 10, "min": 0, "max": 255, "step": 1}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "select_color" + + CATEGORY = "WAS Suite/Image" + + def select_color(self, image, red=255, green=255, blue=255, variance=10): + + if 'opencv-python' not in packages(): + print("\033[34mWAS NS:\033[0m Installing CV2...") + subprocess.check_call( + [sys.executable, '-m', 'pip', '-q', 'install', 'opencv-python']) + + image = self.color_pick(tensor2pil(image), red, green, blue, variance) + + return (pil2tensor(image), ) + + def color_pick(self, image, red=255, green=255, blue=255, variance=10): + # Convert image to RGB mode + image = image.convert('RGB') + + # Create a new black image of the same size as the input image + selected_color = Image.new('RGB', image.size, (0, 0, 0)) + + # Get the width and height of the image + width, height = image.size + + # Loop through every pixel in the image + for x in range(width): + for y in range(height): + # Get the color of the pixel + pixel = image.getpixel((x, y)) + r, g, b = pixel + + # Check if the pixel is within the specified color range + if ((r >= red-variance) and (r <= red+variance) and + (g >= green-variance) and (g <= green+variance) and + (b >= blue-variance) and (b <= blue+variance)): + # Set the pixel in the selected_color image to the RGB value of the pixel + selected_color.putpixel((x, y), (r, g, b)) + + # Return the selected color image + return selected_color + +# IMAGE CONVERT TO CHANNEL + + +class WAS_Image_Select_Channel: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "channel": (['red', 'green', 'blue'],), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "select_channel" + + CATEGORY = "WAS Suite/Image" + + def select_channel(self, image, channel='red'): + + image = self.convert_to_single_channel(tensor2pil(image), channel) + + return (pil2tensor(image), ) + + def convert_to_single_channel(self, image, channel='red'): + + # Convert to RGB mode to access individual channels + image = image.convert('RGB') + + # Extract the desired channel and convert to greyscale + if channel == 'red': + channel_img = image.split()[0].convert('L') + elif channel == 'green': + channel_img = image.split()[1].convert('L') + elif channel == 'blue': + channel_img = image.split()[2].convert('L') + else: + raise ValueError( + "Invalid channel option. Please choose 'red', 'green', or 'blue'.") + + # Convert the greyscale channel back to RGB mode + channel_img = Image.merge( + 'RGB', (channel_img, channel_img, channel_img)) + + return channel_img + + +# IMAGE CONVERT TO CHANNEL + +class WAS_Image_RGB_Merge: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "red_channel": ("IMAGE",), + "green_channel": ("IMAGE",), + "blue_channel": ("IMAGE",), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "merge_channels" + + CATEGORY = "WAS Suite/Image" + + def merge_channels(self, red_channel, green_channel, blue_channel): + + # Apply mix rgb channels + image = self.mix_rgb_channels(tensor2pil(red_channel).convert('L'), tensor2pil( + green_channel).convert('L'), tensor2pil(blue_channel).convert('L')) + + return (pil2tensor(image), ) + + def mix_rgb_channels(self, red, green, blue): + # Create an empty image with the same size as the channels + width, height = red.size + merged_img = Image.new('RGB', (width, height)) + + # Merge the channels into the new image + merged_img = Image.merge('RGB', (red, green, blue)) + + return merged_img + + +# Image Save (NSP Compatible) +# Originally From ComfyUI/nodes.py + +class WAS_Image_Save: + def __init__(self): + self.output_dir = os.path.join(os.getcwd()+os.sep+'ComfyUI', "output") + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE", ), + "output_path": ("STRING", {"default": './ComfyUI/output', "multiline": False}), + "filename_prefix": ("STRING", {"default": "ComfyUI"}), + "extension": (['png', 'jpeg', 'tiff', 'gif'], ), + "quality": ("INT", {"default": 100, "min": 1, "max": 100, "step": 1}), + "overwrite_mode": (["false", "prefix_as_filename"],), + }, + "hidden": { + "prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO" + }, + } + + RETURN_TYPES = () + FUNCTION = "save_images" + + OUTPUT_NODE = True + + CATEGORY = "WAS Suite/IO" + + def save_images(self, images, output_path='', filename_prefix="ComfyUI", extension='png', quality=100, prompt=None, extra_pnginfo=None, overwrite_mode='false'): + def map_filename(filename): + prefix_len = len(filename_prefix) + prefix = filename[:prefix_len + 1] + try: + digits = int(filename[prefix_len + 1:].split('_')[0]) + except: + digits = 0 + return (digits, prefix) + + # Setup custom path or default + if output_path.strip() != '': + if not os.path.exists(output_path.strip()): + print(f'\033[34mWAS NS\033[0m Warning: The path `{output_path.strip()}` specified doesn\'t exist! Creating directory.') + os.mkdir(output_path.strip()) + self.output_dir = os.path.normpath(output_path.strip()) + + # Setup counter + try: + counter = max(filter(lambda a: a[1][:-1] == filename_prefix and a[1] + [-1] == "_", map(map_filename, os.listdir(self.output_dir))))[0] + 1 + except ValueError: + counter = 1 + except FileNotFoundError: + os.mkdir(self.output_dir) + counter = 1 + + paths = list() + for image in images: + i = 255. * image.cpu().numpy() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + metadata = PngInfo() + if prompt is not None: + metadata.add_text("prompt", json.dumps(prompt)) + if extra_pnginfo is not None: + for x in extra_pnginfo: + metadata.add_text(x, json.dumps(extra_pnginfo[x])) + + + if overwrite_mode == 'prefix_as_filename': + file = f"{filename_prefix}.{extension}" + else: + file = f"{filename_prefix}_{counter:05}_.{extension}" + if os.path.exists(os.path.join(self.output_dir, file)): + counter += 1 + file = f"{filename_prefix}_{counter:05}_.{extension}" + + if extension == 'png': + img.save(os.path.join(self.output_dir, file), + pnginfo=metadata, optimize=True) + elif extension == 'webp': + img.save(os.path.join(self.output_dir, file), quality=quality) + elif extension == 'jpeg': + img.save(os.path.join(self.output_dir, file), + quality=quality, optimize=True) + elif extension == 'tiff': + img.save(os.path.join(self.output_dir, file), + quality=quality, optimize=True) + else: + img.save(os.path.join(self.output_dir, file)) + paths.append(file) + if overwrite_mode == 'false': + counter += 1 + + return {"ui": {"images": paths}} + + +# LOAD IMAGE NODE +class WAS_Load_Image: + + def __init__(self): + self.input_dir = os.path.join(os.getcwd()+os.sep+'ComfyUI', "input") + + @classmethod + def INPUT_TYPES(cls): + return {"required": + {"image_path": ( + "STRING", {"default": './ComfyUI/input/example.png', "multiline": False}), } + } + + CATEGORY = "WAS Suite/IO" + + RETURN_TYPES = ("IMAGE", "MASK") + FUNCTION = "load_image" + + def load_image(self, image_path) -> Optional[tuple[torch.Tensor, torch.Tensor]]: + + if image_path.startswith('http'): + from io import BytesIO + i = self.download_image(image_path) + else: + try: + i = Image.open(image_path) + except OSError: + print( + f'\033[34mWAS NS\033[0m Error: The image `{image_path.strip()}` specified doesn\'t exist!') + i = Image.new(mode='RGB', size=(512, 512), color=(0, 0, 0)) + if not i: + return + + image = i + image = np.array(image).astype(np.float32) / 255.0 + image = torch.from_numpy(image)[None,] + + if 'A' in i.getbands(): + mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0 + mask = 1. - torch.from_numpy(mask) + else: + mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu") + return (image, mask) + + def download_image(self, url): + try: + response = requests.get(url) + response.raise_for_status() + img = Image.open(BytesIO(response.content)) + return img + except requests.exceptions.HTTPError as errh: + print(f"\033[34mWAS NS\033[0m Error: HTTP Error: ({url}): {errh}") + except requests.exceptions.ConnectionError as errc: + print( + f"\033[34mWAS NS\033[0m Error: Connection Error: ({url}): {errc}") + except requests.exceptions.Timeout as errt: + print( + f"\033[34mWAS NS\033[0m Error: Timeout Error: ({url}): {errt}") + except requests.exceptions.RequestException as err: + print( + f"\033[34mWAS NS\033[0m Error: Request Exception: ({url}): {err}") + + @classmethod + def IS_CHANGED(cls, image_path): + if image_path.startswith('http'): + return True + m = hashlib.sha256() + with open(image_path, 'rb') as f: + m.update(f.read()) + return m.digest().hex() + + +# TENSOR TO IMAGE NODE + +class WAS_Tensor_Batch_to_Image: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images_batch": ("IMAGE",), + "batch_image_number": ("INT", {"default": 0, "min": 0, "max": 64, "step": 1}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "tensor_batch_to_image" + + CATEGORY = "WAS Suite/Latent" + + def tensor_batch_to_image(self, images_batch=[], batch_image_number=0): + + count = 0 + for _ in images_batch: + if batch_image_number == count: + return (images_batch[batch_image_number].unsqueeze(0), ) + count = count+1 + + print( + f"\033[34mWAS NS\033[0m Error: Batch number `{batch_image_number}` is not defined, returning last image") + return (images_batch[-1].unsqueeze(0), ) + + +#! LATENT NODES + +# IMAGE TO MASK + +class WAS_Image_To_Mask: + + def __init__(self): + self.channels = {'alpha': 'A', 'red': 0, 'green': 1, 'blue': 2} + + @classmethod + def INPUT_TYPES(cls): + return {"required": + {"image": ("IMAGE",), + "channel": (["alpha", "red", "green", "blue"], ), } + } + + CATEGORY = "WAS Suite/Latent" + + RETURN_TYPES = ("MASK",) + + FUNCTION = "image_to_mask" + + def image_to_mask(self, image, channel): + i = tensor2pil(image) + mask = np.array(i.getchannel(self.channels[channel])).astype(np.float32) / 255.0 + mask = 1. - torch.from_numpy(mask) + return (mask, ) + + +# LATENT UPSCALE NODE + +class WAS_Latent_Upscale: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return {"required": {"samples": ("LATENT",), "mode": (["bilinear", "bicubic"],), + "factor": ("FLOAT", {"default": 2.0, "min": 0.1, "max": 8.0, "step": 0.1}), + "align": (["true", "false"], )}} + RETURN_TYPES = ("LATENT",) + FUNCTION = "latent_upscale" + + CATEGORY = "WAS Suite/Latent" + + def latent_upscale(self, samples, mode, factor, align): + s = samples.copy() + s["samples"] = torch.nn.functional.interpolate( + s['samples'], scale_factor=factor, mode=mode, align_corners=(True if align == 'true' else False)) + return (s,) + +# LATENT NOISE INJECTION NODE + + +class WAS_Latent_Noise: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "samples": ("LATENT",), + "noise_std": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}), + } + } + + RETURN_TYPES = ("LATENT",) + FUNCTION = "inject_noise" + + CATEGORY = "WAS Suite/Latent" + + def inject_noise(self, samples, noise_std): + s = samples.copy() + noise = torch.randn_like(s["samples"]) * noise_std + s["samples"] = s["samples"] + noise + return (s,) + + +# MIDAS DEPTH APPROXIMATION NODE + +class MiDaS_Depth_Approx: + def __init__(self): + self.midas_dir = os.path.join(os.getcwd()+os.sep+'ComfyUI', 'models'+os.sep+'midas') + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "use_cpu": (["false", "true"],), + "midas_model": (["DPT_Large", "DPT_Hybrid", "DPT_Small"],), + "invert_depth": (["false", "true"],), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "midas_approx" + + CATEGORY = "WAS Suite/Image" + + def midas_approx(self, image, use_cpu, midas_model, invert_depth): + + global MIDAS_INSTALLED + + if not MIDAS_INSTALLED: + self.install_midas() + + import cv2 as cv + + # Convert the input image tensor to a PIL Image + i = 255. * image.cpu().numpy().squeeze() + img = i + + print("\033[34mWAS NS:\033[0m Downloading and loading MiDaS Model...") + torch.hub.set_dir(self.midas_dir) + midas = torch.hub.load("intel-isl/MiDaS", midas_model, trust_repo=True) + device = torch.device("cuda") if torch.cuda.is_available( + ) and use_cpu == 'false' else torch.device("cpu") + + print('\033[34mWAS NS:\033[0m MiDaS is using device:', device) + + midas.to(device).eval() + midas_transforms = torch.hub.load("intel-isl/MiDaS", "transforms") + + if midas_model == "DPT_Large" or midas_model == "DPT_Hybrid": + transform = midas_transforms.dpt_transform + else: + transform = midas_transforms.small_transform + + img = cv.cvtColor(img, cv.COLOR_BGR2RGB) + input_batch = transform(img).to(device) + + print('\033[34mWAS NS:\033[0m Approximating depth from image.') + + with torch.no_grad(): + prediction = midas(input_batch) + prediction = torch.nn.functional.interpolate( + prediction.unsqueeze(1), + size=img.shape[:2], + mode="bicubic", + align_corners=False, + ).squeeze() + + # Invert depth map + if invert_depth == 'true': + depth = (255 - prediction.cpu().numpy().astype(np.uint8)) + depth = depth.astype(np.float32) + else: + depth = prediction.cpu().numpy().astype(np.float32) + # depth = depth * 255 / (np.max(depth)) / 255 + # Normalize depth to range [0, 1] + depth = (depth - depth.min()) / (depth.max() - depth.min()) + + # depth to RGB + depth = cv.cvtColor(depth, cv.COLOR_GRAY2RGB) + + tensor = torch.from_numpy(depth)[None,] + tensors = (tensor, ) + + del midas, device, midas_transforms + del transform, img, input_batch, prediction + + return tensors + + def install_midas(self): + global MIDAS_INSTALLED + if 'timm' not in packages(): + print("\033[34mWAS NS:\033[0m Installing timm...") + subprocess.check_call( + [sys.executable, '-m', 'pip', '-q', 'install', 'timm']) + if 'opencv-python' not in packages(): + print("\033[34mWAS NS:\033[0m Installing CV2...") + subprocess.check_call( + [sys.executable, '-m', 'pip', '-q', 'install', 'opencv-python']) + MIDAS_INSTALLED = True + +# MIDAS REMOVE BACKGROUND/FOREGROUND NODE + + +class MiDaS_Background_Foreground_Removal: + def __init__(self): + self.midas_dir = os.path.join(os.getcwd()+os.sep+'ComfyUI', 'models'+os.sep+'midas') + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "use_cpu": (["false", "true"],), + "midas_model": (["DPT_Large", "DPT_Hybrid", "DPT_Small"],), + "remove": (["background", "foregroud"],), + "threshold": (["false", "true"],), + "threshold_low": ("FLOAT", {"default": 10, "min": 0, "max": 255, "step": 1}), + "threshold_mid": ("FLOAT", {"default": 200, "min": 0, "max": 255, "step": 1}), + "threshold_high": ("FLOAT", {"default": 210, "min": 0, "max": 255, "step": 1}), + "smoothing": ("FLOAT", {"default": 0.25, "min": 0.0, "max": 16.0, "step": 0.01}), + "background_red": ("INT", {"default": 0, "min": 0, "max": 255, "step": 1}), + "background_green": ("INT", {"default": 0, "min": 0, "max": 255, "step": 1}), + "background_blue": ("INT", {"default": 0, "min": 0, "max": 255, "step": 1}), + }, + } + + RETURN_TYPES = ("IMAGE", "IMAGE") + FUNCTION = "midas_remove" + + CATEGORY = "WAS Suite/Image" + + def midas_remove(self, + image, + midas_model, + use_cpu='false', + remove='background', + threshold='false', + threshold_low=0, + threshold_mid=127, + threshold_high=255, + smoothing=0.25, + background_red=0, + background_green=0, + background_blue=0): + + global MIDAS_INSTALLED + + if not MIDAS_INSTALLED: + self.install_midas() + + import cv2 as cv + + # Convert the input image tensor to a numpy and PIL Image + i = 255. * image.cpu().numpy().squeeze() + img = i + # Original image + img_original = tensor2pil(image).convert('RGB') + + print("\033[34mWAS NS:\033[0m Downloading and loading MiDaS Model...") + torch.hub.set_dir(self.midas_dir) + midas = torch.hub.load("intel-isl/MiDaS", midas_model, trust_repo=True) + device = torch.device("cuda") if torch.cuda.is_available( + ) and use_cpu == 'false' else torch.device("cpu") + + print('\033[34mWAS NS:\033[0m MiDaS is using device:', device) + + midas.to(device).eval() + midas_transforms = torch.hub.load("intel-isl/MiDaS", "transforms") + + if midas_model == "DPT_Large" or midas_model == "DPT_Hybrid": + transform = midas_transforms.dpt_transform + else: + transform = midas_transforms.small_transform + + img = cv.cvtColor(img, cv.COLOR_BGR2RGB) + input_batch = transform(img).to(device) + + print('\033[34mWAS NS:\033[0m Approximating depth from image.') + + with torch.no_grad(): + prediction = midas(input_batch) + prediction = torch.nn.functional.interpolate( + prediction.unsqueeze(1), + size=img.shape[:2], + mode="bicubic", + align_corners=False, + ).squeeze() + + # Invert depth map + if remove == 'foreground': + depth = (255 - prediction.cpu().numpy().astype(np.uint8)) + depth = depth.astype(np.float32) + else: + depth = prediction.cpu().numpy().astype(np.float32) + depth = depth * 255 / (np.max(depth)) / 255 + depth = Image.fromarray(np.uint8(depth * 255)) + + # Threshold depth mask + if threshold == 'true': + levels = self.AdjustLevels( + threshold_low, threshold_mid, threshold_high) + depth = levels.adjust(depth.convert('RGB')).convert('L') + if smoothing > 0: + depth = depth.filter(ImageFilter.GaussianBlur(radius=smoothing)) + depth = depth.resize(img_original.size).convert('L') + + # Validate background color arguments + background_red = int(background_red) if isinstance( + background_red, (int, float)) else 0 + background_green = int(background_green) if isinstance( + background_green, (int, float)) else 0 + background_blue = int(background_blue) if isinstance( + background_blue, (int, float)) else 0 + + # Create background color tuple + background_color = (background_red, background_green, background_blue) + + # Create background image + background = Image.new( + mode="RGB", size=img_original.size, color=background_color) + + # Composite final image + result_img = Image.composite(img_original, background, depth) + + del midas, device, midas_transforms + del transform, img, img_original, input_batch, prediction + + return (pil2tensor(result_img), pil2tensor(depth.convert('RGB'))) + + class AdjustLevels: + def __init__(self, min_level, mid_level, max_level): + self.min_level = min_level + self.mid_level = mid_level + self.max_level = max_level + + def adjust(self, im): + # load the image + + # convert the image to a numpy array + im_arr = np.array(im) + + # apply the min level adjustment + im_arr[im_arr < self.min_level] = self.min_level + + # apply the mid level adjustment + im_arr = (im_arr - self.min_level) * \ + (255 / (self.max_level - self.min_level)) + im_arr[im_arr < 0] = 0 + im_arr[im_arr > 255] = 255 + im_arr = im_arr.astype(np.uint8) + + # apply the max level adjustment + im = Image.fromarray(im_arr) + im = ImageOps.autocontrast(im, cutoff=self.max_level) + + return im + + def install_midas(self): + global MIDAS_INSTALLED + if 'timm' not in packages(): + print("\033[34mWAS NS:\033[0m Installing timm...") + subprocess.check_call( + [sys.executable, '-m', 'pip', '-q', 'install', 'timm']) + if 'opencv-python' not in packages(): + print("\033[34mWAS NS:\033[0m Installing CV2...") + subprocess.check_call( + [sys.executable, '-m', 'pip', '-q', 'install', 'opencv-python']) + MIDAS_INSTALLED = True + + +#! CONDITIONING NODES + + +# NSP CLIPTextEncode NODE + +class WAS_NSP_CLIPTextEncoder: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "noodle_key": ("STRING", {"default": '__', "multiline": False}), + "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), + "text": ("STRING", {"multiline": True}), + "clip": ("CLIP",), + } + } + + OUTPUT_NODE = True + RETURN_TYPES = ("CONDITIONING",) + FUNCTION = "nsp_encode" + + CATEGORY = "WAS Suite/Conditioning" + + def nsp_encode(self, clip, text, noodle_key='__', seed=0): + + # Fetch the NSP Pantry + local_pantry = os.getcwd()+os.sep+'ComfyUI'+os.sep+'custom_nodes'+os.sep+'nsp_pantry.json' + if not os.path.exists(local_pantry): + response = urlopen('https://raw.githubusercontent.com/WASasquatch/noodle-soup-prompts/main/nsp_pantry.json') + tmp_pantry = json.loads(response.read()) + # Dump JSON locally + pantry_serialized = json.dumps(tmp_pantry, indent=4) + with open(local_pantry, "w") as f: + f.write(pantry_serialized) + del response, tmp_pantry + + # Load local pantry + with open(local_pantry, 'r') as f: + nspterminology = json.load(f) + + if seed > 0 or seed < 1: + random.seed(seed) + + # Parse Text + new_text = text + for term in nspterminology: + # Target Noodle + tkey = f'{noodle_key}{term}{noodle_key}' + # How many occurances? + tcount = new_text.count(tkey) + # Apply random results for each noodle counted + for _ in range(tcount): + new_text = new_text.replace( + tkey, random.choice(nspterminology[term]), 1) + seed = seed+1 + random.seed(seed) + + print('\033[34mWAS NS\033[0m CLIPTextEncode NSP:', new_text) + + return ([[clip.encode(new_text), {}]], {"ui": {"prompt": new_text}}) + + +#! SAMPLING NODES + +# KSAMPLER + +class WAS_KSampler: + @classmethod + def INPUT_TYPES(cls): + return {"required": + + {"model": ("MODEL",), + "seed": ("SEED",), + "steps": ("INT", {"default": 20, "min": 1, "max": 10000}), + "cfg": ("FLOAT", {"default": 8.0, "min": 0.0, "max": 100.0}), + "sampler_name": (comfy.samplers.KSampler.SAMPLERS, ), + "scheduler": (comfy.samplers.KSampler.SCHEDULERS, ), + "positive": ("CONDITIONING", ), + "negative": ("CONDITIONING", ), + "latent_image": ("LATENT", ), + "denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + }} + + RETURN_TYPES = ("LATENT",) + FUNCTION = "sample" + + CATEGORY = "WAS Suite/Sampling" + + def sample(self, model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image, denoise=1.0): + return nodes.common_ksampler(model, seed['seed'], steps, cfg, sampler_name, scheduler, positive, negative, latent_image, denoise=denoise) + +# SEED NODE + + +class WAS_Seed: + @classmethod + def INPUT_TYPES(cls): + return {"required": + {"seed": ("INT", {"default": 0, "min": 0, + "max": 0xffffffffffffffff})} + } + + RETURN_TYPES = ("SEED",) + FUNCTION = "seed" + + CATEGORY = "WAS Suite/Constant" + + def seed(self, seed): + return ({"seed": seed, }, ) + + +#! TEXT NODES + +# Text Multiline Node + +class WAS_Text_Multiline: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("STRING", {"default": '', "multiline": True}), + } + } + RETURN_TYPES = ("ASCII",) + FUNCTION = "text_multiline" + + CATEGORY = "WAS Suite/Text" + + def text_multiline(self, text): + return (text, ) + + +# Text String Node + +class WAS_Text_String: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("STRING", {"default": '', "multiline": False}), + } + } + RETURN_TYPES = ("ASCII",) + FUNCTION = "text_string" + + CATEGORY = "WAS Suite/Text" + + def text_string(self, text): + return (text, ) + + +# Text Random Line + +class WAS_Text_Random_Line: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("ASCII",), + "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), + } + } + + RETURN_TYPES = ("ASCII",) + FUNCTION = "text_random_line" + + CATEGORY = "WAS Suite/Text" + + def text_random_line(self, text, seed): + lines = text.split("\n") + random.seed(seed) + choice = random.choice(lines) + return (choice, ) + + +# Text Concatenate + +class WAS_Text_Concatenate: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text_a": ("ASCII",), + "text_b": ("ASCII",), + "linebreak_addition": (['true', 'false'], ), + } + } + + RETURN_TYPES = ("ASCII",) + FUNCTION = "text_concatenate" + + CATEGORY = "WAS Suite/Text" + + def text_concatenate(self, text_a, text_b, linebreak_addition): + return (text_a + ("\n" if linebreak_addition == 'true' else '') + text_b, ) + + +# Text Search and Replace + +class WAS_Search_and_Replace: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("ASCII",), + "find": ("STRING", {"default": '', "multiline": False}), + "replace": ("STRING", {"default": '', "multiline": False}), + } + } + + RETURN_TYPES = ("ASCII",) + FUNCTION = "text_search_and_replace" + + CATEGORY = "WAS Suite/Text" + + def text_search_and_replace(self, text, find, replace): + return (self.replace_substring(text, find, replace), ) + + def replace_substring(self, text, find, replace): + import re + text = re.sub(find, replace, text) + return text + + +# Text Search and Replace + +class WAS_Search_and_Replace_Input: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("ASCII",), + "find": ("ASCII",), + "replace": ("ASCII",), + } + } + + RETURN_TYPES = ("ASCII",) + FUNCTION = "text_search_and_replace" + + CATEGORY = "WAS Suite/Text" + + def text_search_and_replace(self, text, find, replace): + return (self.replace_substring(text, find, replace), ) + + def replace_substring(self, text, find, replace): + import re + text = re.sub(find, replace, text) + return text + + +# Text Parse NSP + +class WAS_Text_Parse_NSP: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "noodle_key": ("STRING", {"default": '__', "multiline": False}), + "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), + "text": ("ASCII",), + } + } + + OUTPUT_NODE = True + RETURN_TYPES = ("ASCII",) + FUNCTION = "text_parse_nsp" + + CATEGORY = "WAS Suite/Text" + + def text_parse_nsp(self, text, noodle_key='__', seed=0): + + # Fetch the NSP Pantry + local_pantry = os.getcwd()+os.sep+'ComfyUI'+os.sep+'custom_nodes'+os.sep+'nsp_pantry.json' + if not os.path.exists(local_pantry): + response = urlopen('https://raw.githubusercontent.com/WASasquatch/noodle-soup-prompts/main/nsp_pantry.json') + tmp_pantry = json.loads(response.read()) + # Dump JSON locally + pantry_serialized = json.dumps(tmp_pantry, indent=4) + with open(local_pantry, "w") as f: + f.write(pantry_serialized) + del response, tmp_pantry + + # Load local pantry + with open(local_pantry, 'r') as f: + nspterminology = json.load(f) + + if seed > 0 or seed < 1: + random.seed(seed) + + # Parse Text + new_text = text + for term in nspterminology: + # Target Noodle + tkey = f'{noodle_key}{term}{noodle_key}' + # How many occurances? + tcount = new_text.count(tkey) + # Apply random results for each noodle counted + for _ in range(tcount): + new_text = new_text.replace( + tkey, random.choice(nspterminology[term]), 1) + seed = seed+1 + random.seed(seed) + + print('\033[34mWAS NS\033[0m Text Parse NSP:', new_text) + + return (new_text, ) + + +# TEXT SEARCH AND REPLACE + +class WAS_Text_Save: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("ASCII",), + "path": ("STRING", {"default": '', "multiline": False}), + "filename": ("STRING", {"default": f'text_[time]', "multiline": False}), + } + } + + OUTPUT_NODE = True + RETURN_TYPES = () + FUNCTION = "save_text_file" + + CATEGORY = "WAS Suite/IO" + + def save_text_file(self, text, path, filename): + + # Ensure path exists + if not os.path.exists(path): + print( + f'\033[34mWAS NS\033[0m Error: The path `{path}` doesn\'t exist!') + + # Ensure content to save + if text.strip == '': + print( + f'\033[34mWAS NS\033[0m Error: There is no text specified to save! Text is empty.') + + # Replace tokens + tokens = { + '[time]': f'{round(time.time())}', + } + for k in tokens.keys(): + filename = self.replace_substring(filename, k, tokens[k]) + + # Write text file + self.writeTextFile(os.path.join(path, filename + '.txt'), text) + + return (text, ) + + # Save Text FileNotFoundError + def writeTextFile(self, file, content): + try: + with open(file, 'w', encoding='utf-8', newline='\n') as f: + f.write(content) + except OSError: + print( + f'\033[34mWAS Node Suite\033[0m Error: Unable to save file `{file}`') + + # Replace a substring + def replace_substring(self, string, substring, replacement): + import re + pattern = re.compile(re.escape(substring)) + string = pattern.sub(replacement, string) + return string + + + +# TEXT TO CONDITIONIONG + +class WAS_Text_to_Conditioning: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "clip": ("CLIP",), + "text": ("ASCII",), + } + } + + RETURN_TYPES = ("CONDITIONING",) + FUNCTION = "text_to_conditioning" + + CATEGORY = "WAS Suite/Text" + + def text_to_conditioning(self, clip, text): + return ([[clip.encode(text), {}]], ) + + +class WAS_Text_to_Console: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("ASCII",), + "label": ("STRING", {"default": f'Text Output', "multiline": False}), + } + } + + RETURN_TYPES = ("ASCII",) + OUTPUT_NODE = True + FUNCTION = "text_to_console" + + CATEGORY = "WAS Suite/Text" + + def text_to_console(self, text, label): + if label.strip() != '': + print(f'\033[34mWAS Node Suite \033[33m{label}\033[0m:\n{text}\n') + else: + print( + f'\033[34mWAS Node Suite \033[33mText to Console\033[0m:\n{text}\n') + return (text, ) + + +# LOAD TEXT FILE + +class WAS_Text_Load_From_File: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "file_path": ("STRING", {"default": '', "multiline": False}), + } + } + + RETURN_TYPES = ("ASCII",) + FUNCTION = "load_file" + + CATEGORY = "WAS Suite/IO" + + def load_file(self, file_path=''): + return (self.load_text_file(file_path), ) + + def load_text_file(self, path): + if not os.path.exists(path): + print( + f'\033[34mWAS Node Suite\033[0m Error: The path `{path}` specified cannot be found.') + return '' + with open(path, 'r', encoding="utf-8", newline='\n') as file: + text = file.read() + return text + +# LOAD TEXT TO STRING + +class WAS_Text_To_String: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("ASCII",), + } + } + + RETURN_TYPES = ("STRING",) + FUNCTION = "text_to_string" + + CATEGORY = "WAS Suite/Text" + + def text_to_string(self, text): + return (text, ) + + +#! NUMBERS + + +# RANDOM NUMBER + +class WAS_Random_Number: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "number_type": (["integer", "float", "bool"],), + "minimum": ("FLOAT", {"default": 0, "min": -18446744073709551615, "max": 18446744073709551615}), + "maximum": ("FLOAT", {"default": 0, "min": -18446744073709551615, "max": 18446744073709551615}), + "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), + } + } + + RETURN_TYPES = ("NUMBER",) + FUNCTION = "return_randm_number" + + CATEGORY = "WAS Suite/Constant" + + def return_randm_number(self, minimum, maximum, seed, number_type='integer'): + + # Set Generator Seed + random.seed(seed) + + # Return random number + match number_type: + case 'integer': + number = random.randint(minimum, maximum) + case 'float': + number = random.uniform(minimum, maximum) + case 'bool': + number = random.random() + case _: + return + + # Return number + return (number, ) + + +# CONSTANT NUMBER + +class WAS_Constant_Number: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "number_type": (["integer", "float", "bool"],), + "number": ("FLOAT", {"default": 0, "min": -18446744073709551615, "max": 18446744073709551615}), + } + } + + RETURN_TYPES = ("NUMBER",) + FUNCTION = "return_constant_number" + + CATEGORY = "WAS Suite/Constant" + + def return_constant_number(self, number_type, number): + + # Return number + match number_type: + case 'integer': + return (int(number), ) + case 'integer': + return (float(number), ) + case 'bool': + return ((1 if int(number) > 0 else 0), ) + + +# NUMBER TO SEED + +class WAS_Number_To_Seed: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "number": ("NUMBER",), + } + } + + RETURN_TYPES = ("SEED",) + FUNCTION = "number_to_seed" + + CATEGORY = "WAS Suite/Constant" + + def number_to_seed(self, number): + return ({"seed": number, }, ) + + +# NUMBER TO INT + +class WAS_Number_To_Int: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "number": ("NUMBER",), + } + } + + RETURN_TYPES = ("INT",) + FUNCTION = "number_to_int" + + CATEGORY = "WAS Suite/Constant" + + def number_to_int(self, number): + return (int(number), ) + + + +# NUMBER TO FLOAT + +class WAS_Number_To_Float: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "number": ("NUMBER",), + } + } + + RETURN_TYPES = ("FLOAT",) + FUNCTION = "number_to_float" + + CATEGORY = "WAS Suite/Constant" + + def number_to_float(self, number): + return (float(number), ) + + + +# INT TO NUMBER + +class WAS_Int_To_Number: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "int_input": ("INT",), + } + } + + RETURN_TYPES = ("NUMBER",) + FUNCTION = "int_to_number" + + CATEGORY = "WAS Suite/Constant" + + def int_to_number(self, int_input): + return (int_input, ) + + + +# NUMBER TO FLOAT + +class WAS_Float_To_Number: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "float_input": ("FLOAT",), + } + } + + RETURN_TYPES = ("NUMBER",) + FUNCTION = "float_to_number" + + CATEGORY = "WAS Suite/Constant" + + def float_to_number(self, float_input): + return ( float_input, ) + + +# NUMBER TO STRING + +class WAS_Number_To_String: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "number": ("NUMBER",), + } + } + + RETURN_TYPES = ("STRING",) + FUNCTION = "number_to_string" + + CATEGORY = "WAS Suite/Constant" + + def number_to_string(self, number): + return ( str(number), ) + +# NUMBER TO STRING + +class WAS_Number_To_Text: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "number": ("NUMBER",), + } + } + + RETURN_TYPES = ("ASCII",) + FUNCTION = "number_to_text" + + CATEGORY = "WAS Suite/Constant" + + def number_to_text(self, number): + return ( str(number), ) + + +# NUMBER PI + +class WAS_Number_PI: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": {} + } + + RETURN_TYPES = ("NUMBER",) + FUNCTION = "number_pi" + + CATEGORY = "WAS Suite/Constant" + + def number_pi(self): + return (math.pi, ) + +# NUMBER OPERATIONS + + +class WAS_Number_Operation: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "number_a": ("NUMBER",), + "number_b": ("NUMBER",), + "operation": (["addition", "subtraction", "division", "floor division", "multiplication", "exponentiation", "modulus", "greater-than", "greater-than or equels", "less-than", "less-than or equals", "equals", "does not equal"],), + } + } + + RETURN_TYPES = ("NUMBER",) + FUNCTION = "math_operations" + + CATEGORY = "WAS Suite/Operations" + + def math_operations(self, number_a, number_b, operation="addition"): + + # Return random number + match operation: + case 'addition': + return ((number_a + number_b), ) + case 'subtraction': + return ((number_a - number_b), ) + case 'division': + return ((number_a / number_b), ) + case 'floor division': + return ((number_a // number_b), ) + case 'multiplication': + return ((number_a * number_b), ) + case 'exponentiation': + return ((number_a ** number_b), ) + case 'modulus': + return ((number_a % number_b), ) + case 'greater-than': + return (+(number_a > number_b), ) + case 'greater-than or equals': + return (+(number_a >= number_b), ) + case 'less-than': + return (+(number_a < number_b), ) + case 'less-than or equals': + return (+(number_a <= number_b), ) + case 'equals': + return (+(number_a == number_b), ) + case 'does not equal': + return (+(number_a != number_b), ) + + +#! MISC + + +# INPUT SWITCH + +class WAS_Input_Switch: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "input_a": ("*",), + "input_b": ("*",), + "boolean": ("NUMBER",), + } + } + + RETURN_TYPES = ("*",) + FUNCTION = "input_switch" + + CATEGORY = "WAS Suite/Operations" + + def input_switch(self, input_a, input_b, boolean=0): + + if int(boolean) == 1: + return (input_a, ) + else: + return (input_b, ) + + +# DEBUG INPUT TO CONSOLE + + +class WAS_Debug_Number_to_Console: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "number": ("NUMBER",), + "label": ("STRING", {"default": 'Debug to Console', "multiline": False}), + } + } + + RETURN_TYPES = ("NUMBER",) + OUTPUT_NODE = True + FUNCTION = "debug_to_console" + + CATEGORY = "WAS Suite/Debug" + + def debug_to_console(self, number, label): + if label.strip() != '': + print(f'\033[34mWAS Node Suite \033[33m{label}\033[0m:\n{number}\n') + else: + print(f'\033[34mWAS Node Suite \033[33mDebug to Console\033[0m:\n{number}\n') + return (number, ) + + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("NaN") + + +# NODE MAPPING +NODE_CLASS_MAPPINGS = { + "CLIPTextEncode (NSP)": WAS_NSP_CLIPTextEncoder, + "Constant Number": WAS_Constant_Number, + "Debug Number to Console": WAS_Debug_Number_to_Console, + "Float to Number": WAS_Float_To_Number, + "Image Analyze": WAS_Image_Analyze, + "Image Blank": WAS_Image_Blank, + "Image Blend by Mask": WAS_Image_Blend_Mask, + "Image Blend": WAS_Image_Blend, + "Image Blending Mode": WAS_Image_Blending_Mode, + "Image Bloom Filter": WAS_Image_Bloom_Filter, + "Image Canny Filter": WAS_Canny_Filter, + "Image Chromatic Aberration": WAS_Image_Chromatic_Aberration, + "Image Color Palette": WAS_Image_Color_Palette, + "Image Edge Detection Filter": WAS_Image_Edge, + "Image Film Grain": WAS_Film_Grain, + "Image Filter Adjustments": WAS_Image_Filters, + "Image Flip": WAS_Image_Flip, + "Image Gradient Map": WAS_Image_Gradient_Map, + "Image Generate Gradient": WAS_Image_Generate_Gradient, + "Image High Pass Filter": WAS_Image_High_Pass_Filter, + "Image Levels Adjustment": WAS_Image_Levels, + "Image Load": WAS_Load_Image, + "Image Median Filter": WAS_Image_Median_Filter, + "Image Mix RGB Channels": WAS_Image_RGB_Merge, + "Image Monitor Effects Filter": WAS_Image_Monitor_Distortion_Filter, + "Image Nova Filter": WAS_Image_Nova_Filter, + "Image Padding": WAS_Image_Padding, + "Image Remove Background (Alpha)": WAS_Remove_Background, + "Image Remove Color": WAS_Image_Remove_Color, + "Image Resize": WAS_Image_Rescale, + "Image Rotate": WAS_Image_Rotate, + "Image Save": WAS_Image_Save, + "Image Select Channel": WAS_Image_Select_Channel, + "Image Select Color": WAS_Image_Select_Color, + "Image Style Filter": WAS_Image_Style_Filter, + "Image Threshold": WAS_Image_Threshold, + "Image Transpose": WAS_Image_Transpose, + "Image fDOF Filter": WAS_Image_fDOF, + "Image to Latent Mask": WAS_Image_To_Mask, + "Int to Number": WAS_Int_To_Number, + "KSampler (WAS)": WAS_KSampler, + "Latent Noise Injection": WAS_Latent_Noise, + "Latent Upscale by Factor (WAS)": WAS_Latent_Upscale, + "Load Image Batch": WAS_Load_Image_Batch, + "Load Text File": WAS_Text_Load_From_File, + "MiDaS Depth Approximation": MiDaS_Depth_Approx, + "MiDaS Mask Image": MiDaS_Background_Foreground_Removal, + "Number Operation": WAS_Number_Operation, + "Number to Float": WAS_Number_To_Float, + "Number PI": WAS_Number_PI, + "Number to Int": WAS_Number_To_Int, + "Number to Seed": WAS_Number_To_Seed, + "Number to String": WAS_Number_To_String, + "Number to Text": WAS_Number_To_Text, + "Random Number": WAS_Random_Number, + "Save Text File": WAS_Text_Save, + "Seed": WAS_Seed, + "Tensor Batch to Image": WAS_Tensor_Batch_to_Image, + "Text Concatenate": WAS_Text_Concatenate, + "Text Find and Replace Input": WAS_Search_and_Replace_Input, + "Text Find and Replace": WAS_Search_and_Replace, + "Text Multiline": WAS_Text_Multiline, + "Text Parse Noodle Soup Prompts": WAS_Text_Parse_NSP, + "Text Random Line": WAS_Text_Random_Line, + "Text String": WAS_Text_String, + "Text to Conditioning": WAS_Text_to_Conditioning, + "Text to Console": WAS_Text_to_Console, + "Text to String": WAS_Text_To_String, +} + +print('\033[34mWAS Node Suite: \033[92mLoaded\033[0m')