Files
PressWagon-ComfyUI-StringsA…/strings_and_things.py
T
PressWagon 6e584bee8b Improvements to the ImageDiff node and updated demonstration workflow
The ImageDiff node now has a pair of toggles: when console_mse is false the Mean Square Error calculation is skipped; when contrast_boost is true the difference between images is boosted by a linear scaling factor (currently set to 5).

The calculation of difference has also been modified to take the absolute value, the resulting image is now purely magnitudinal and does not include directionality of change (I think this should pretty much always be better suited to the imagined use case).
2025-04-12 13:07:47 +10:00

442 lines
16 KiB
Python

import folder_paths
import torch
from torchvision.transforms import ToPILImage, ToTensor
from scipy.fftpack import fft2, fftshift
from PIL import Image
import numpy as np
class AnyType(str):
"""A special type that can be connected to any other types. Credit to pythongosssss"""
def __ne__(self, __value: object) -> bool:
return False
any_type = AnyType("*")
# Picks a Lora as an output to both send to a lora loader and the metadata as a string
class PWLoraSelector:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"lora_name": (folder_paths.get_filename_list("loras"),), # List of LoRA filenames
}
}
CATEGORY = 'Strings&Things'
RETURN_TYPES = (folder_paths.get_filename_list("loras"), "STRING",)
RETURN_NAMES = ("LORA_NAME", "metadata",)
FUNCTION = "get_lora_name"
def get_lora_name(self, lora_name):
output = str(lora_name)
return (output, output)
# Takes up to 5 Lora names. Should now support chaining together multiple PWLoraNameCollector nodes.
class PWLoraNameCollector:
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"Lora_A": ("STRING",),
"Lora_B": ("STRING",),
"Lora_C": ("STRING",),
"Lora_D": ("STRING",),
"Lora_E": ("STRING",),
}
}
CATEGORY = 'Strings&Things'
RETURN_TYPES = (any_type,)
RETURN_NAMES = ("LORA_NAMES",)
FUNCTION = "LoraNameConcat"
def LoraNameConcat(self, Lora_A=None, Lora_B=None, Lora_C=None, Lora_D=None, Lora_E=None):
# Collect all inputs into a list
inputs = [Lora_A, Lora_B, Lora_C, Lora_D, Lora_E]
# Flatten inputs to handle cases where any of them might already be a list
lora_names = []
for item in inputs:
if item: # Ignore None values
if isinstance(item, list):
lora_names.extend(item) # Unpack list items
else:
lora_names.append(item) # Append single string
return (lora_names,)
# A node for printing string data to the console
class DebugString:
CATEGORY = "Strings&Things"
FUNCTION = "PrintString"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"DebugString": (any_type,),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("DebugOutput",) # Optional output, maybe it'll come in handy some day.
OUTPUT_NODE = True
def PrintString(self, DebugString):
DebugString=str(DebugString)
print(f"\033[1;33mDebugString: {DebugString}\033[0m") # Output the string to the console for debugging
return (DebugString,) # Return the input string for potential chaining
class FormatConcatStrings:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"separator": ("STRING", {
"multiline": False,
"default": " ",
}),
},
"optional": {
"A_Pre_string": ("STRING", {
"multiline": False,
}),
"A_Body_string": ("STRING", {
"multiline": False,
}),
"A_Post_string": ("STRING", {
"multiline": False,
}),
"B_Pre_string": ("STRING", {
"multiline": False,
}),
"B_Body_string": ("STRING", {
"multiline": False,
}),
"B_Post_string": ("STRING", {
"multiline": False,
}),
"C_Pre_string": ("STRING", {
"multiline": False,
}),
"C_Body_string": ("STRING", {
"multiline": False,
}),
"C_Post_string": ("STRING", {
"multiline": False,
}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("FormattedString",)
FUNCTION = "triple_format_concat_strings"
OUTPUT_NODE = True
CATEGORY = "Strings&Things"
def triple_format_concat_strings(self, separator=" ", A_Pre_string="", A_Body_string="", A_Post_string="", B_Pre_string="", B_Body_string="", B_Post_string="", C_Pre_string="", C_Body_string="", C_Post_string=""):
A_part = (A_Pre_string + A_Body_string + A_Post_string) if A_Body_string else ""
B_part = (B_Pre_string + B_Body_string + B_Post_string) if B_Body_string else ""
C_part = (C_Pre_string + C_Body_string + C_Post_string) if C_Body_string else ""
formatted_string = separator.join(filter(None, [A_part, B_part, C_part]))
return (formatted_string,)
# A node for adding pre and post text. Will only output if Body is not empty.
class FormattingSingle:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"optional": {
"PRE_string": ("STRING", {
"multiline": False,
}),
"BODY_string": ("STRING", {
"multiline": False,
}),
"POST_string": ("STRING", {
"multiline": False,
}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("FormattedString",)
FUNCTION = "format_single"
OUTPUT_NODE = True
CATEGORY = "Strings&Things"
def format_single (self, PRE_string="", BODY_string="", POST_string=""):
if not BODY_string: # BODY_string is empty or None to avoid formatting empty strings
formatted_string = ""
else:
formatted_string = PRE_string + BODY_string + POST_string
return (formatted_string,)
""" Part 2
Weird nodes that may or may not be useful
-Image mosaic censor node
-Fourier analysis node
-[SDXL & 1.5 Only for now] Cosine Similarity & Euclidean Distance
"""
# Applies a mosaic effect to the entire image. Can be composited back onto the original
class MosaicEffectNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"tile_size": ("INT", {
"default": 10,
"min": 1,
"max": 100,
"step": 1
}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("MosaicImage",)
FUNCTION = "apply_mosaic"
CATEGORY = "Strings&Things/Extras"
OUTPUT_NODE = True
def apply_mosaic(self, image, tile_size=10):
# Rearrange the tensor from Comfy's [batch_size, height, width, channels] format to PIL's [batch_size, channels, height, width] format
image = image.permute(0, 3, 1, 2)
# Convert the tensor to a PIL image (processing only the first image in the batch)
image = ToPILImage()(image[0])
# Get image dimensions
width, height = image.size
# Create a new image for the output (same size)
mosaic_image = Image.new("RGB", (width, height))
# Process the image in tiles
for y in range(0, height, tile_size):
for x in range(0, width, tile_size):
# Define the tile box
box = (x, y, x + tile_size, y + tile_size)
# Crop the tile from the image
tile = image.crop(box)
# Get the median color of the tile
median_color = self.get_median_color(tile)
# Fill the tile area in the output image with the median color
for yy in range(y, min(y + tile_size, height)):
for xx in range(x, min(x + tile_size, width)):
mosaic_image.putpixel((xx, yy), median_color)
# Convert the PIL image back to tensor
mosaic_image_tensor = ToTensor()(mosaic_image).unsqueeze(0) # Add batch dimension back
mosaic_image_tensor = mosaic_image_tensor.permute(0, 2, 3, 1)
return (mosaic_image_tensor,)
@staticmethod
def get_median_color(tile):
# Convert the tile to a numpy array
pixels = np.array(tile)
# Calculate the median color for each channel
r = np.median(pixels[:, :, 0])
g = np.median(pixels[:, :, 1])
b = np.median(pixels[:, :, 2])
return int(r), int(g), int(b)
class FourierAnalysisNode:
@staticmethod
def INPUT_TYPES():
return {
"required": {
"image": ("IMAGE",),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("Image",)
FUNCTION = "FAnalysis"
OUTPUT_NODE = True
CATEGORY = "Strings&Things/Extras"
def FAnalysis(self,image):
# Rearrange the tensor from [batch_size, height, width, channels] to [batch_size, channels, height, width]
image = image.permute(0, 3, 1, 2)
# Dropping batch dimension
image = image.squeeze(0)
# Perform Fourier Transform on each channel separately
fft_r = fft2(image[0].numpy()) # Red channel
fft_g = fft2(image[1].numpy()) # Green channel
fft_b = fft2(image[2].numpy()) # Blue channel
# Shift the zero frequency component to the center
fft_r_shifted = fftshift(fft_r)
fft_g_shifted = fftshift(fft_g)
fft_b_shifted = fftshift(fft_b)
# Take log of the magnitude for visualization and normalize to 0-255 range
magnitude_r = np.log(1 + np.abs(fft_r_shifted))
magnitude_g = np.log(1 + np.abs(fft_g_shifted))
magnitude_b = np.log(1 + np.abs(fft_b_shifted))
# Normalize each channel to range [0, 255]
magnitude_r = (magnitude_r - np.min(magnitude_r)) / (np.max(magnitude_r) - np.min(magnitude_r)) * 255
magnitude_g = (magnitude_g - np.min(magnitude_g)) / (np.max(magnitude_g) - np.min(magnitude_g)) * 255
magnitude_b = (magnitude_b - np.min(magnitude_b)) / (np.max(magnitude_b) - np.min(magnitude_b)) * 255
# Convert the magnitude to uint8 type
magnitude_r = magnitude_r.astype(np.uint8)
magnitude_g = magnitude_g.astype(np.uint8)
magnitude_b = magnitude_b.astype(np.uint8)
# Stack the channels back into a 3D array (height, width, 3)
magnitude_image = np.stack([magnitude_r, magnitude_g, magnitude_b], axis=-1)
# Convert the magnitude image back to a PIL image
fourier_image_pil = Image.fromarray(magnitude_image)
# Convert back to tensor
image = ToTensor()(fourier_image_pil).unsqueeze(0) # Add batch dimension back
image = image.permute(0, 2, 3, 1) # Converting from [B, C, H, W] to [B, H, W, C]
return (image,)
# Takes two text inputs and two CLIP inputs and calculates the Cosine Similarity and Euclidean distance between them
class TextEmbeddingsInterrogator:
FUNCTION = "interrogation"
CATEGORY = "Strings&Things/Extras"
DESCRIPTION = "Calculates the Cosine Similarity and Euclidean Distance between two text embeddings"
OUTPUT_NODE = True
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("Output string",)
@staticmethod
def INPUT_TYPES():
return {
"required": {
"CLIP_1": ("CLIP",),
"Text_1": ("STRING",{"default":""}),
"CLIP_2": ("CLIP",),
"Text_2": ("STRING",{"default":""}),
}
}
def get_text_embeddings(self, clip, text):
# Tokenize text
tokens = clip.tokenize(text)
embeddings = clip.encode_from_tokens_scheduled(tokens)
# embeddings is a list of lists of tensors, we want the only entry in the the top level list (hence [0]) and the second entry of that subordinate list (hence [0][1])
# but we need to reference the dictionary key to extract the element from that tensor to get the Pooled Attention
pooled_embeddings = embeddings[0][1]['pooled_output']
return pooled_embeddings
def interrogation(self, Text_1, Text_2, CLIP_1, CLIP_2):
#get text embeddings
embedding1 = self.get_text_embeddings(CLIP_1, Text_1)
embedding2 = self.get_text_embeddings(CLIP_2, Text_2)
# Normalize the embeddings
embedding1_norm = embedding1 / embedding1.norm(p=2)
embedding2_norm = embedding2 / embedding2.norm(p=2)
# Compute the cosine similarity as a dot product between the normalized vectors
cos_sim = torch.mm(embedding1_norm, embedding2_norm.T)
cos_sim_value = cos_sim.item()
# Calculate distance between embeddings
distance = torch.norm(embedding1 - embedding2, p=2)
distance_value = distance.item()
print(f'\033[1;33mText: "{Text_1}", "{Text_2}"\033[0m')
print(f"\033[1;33mCosSim: {cos_sim_value}\033[0m")
print(f"\033[1;33mEuclidean Distance: {distance_value}\033[0m")
output_string = f"Cosine Similarity: {cos_sim_value}, Euclidean Distance: {distance_value}"
return output_string
# Takes two images and outputs an image of the pixel-wise difference
class ImageDifference:
FUNCTION = "ImageDiff"
CATEGORY = "Strings&Things/Extras"
DESCRIPTION = "Pairwise image comparrison"
OUTPUT_NODE = True
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
@staticmethod
def INPUT_TYPES():
return {
"required": {
"image1": ("IMAGE",),
"image2": ("IMAGE",),
"contrast_boost": ("BOOLEAN", {
"default": False
}),
"console_mse": ("BOOLEAN", {
"default": True
}),
}
}
def ImageDiff(self, image1, image2, contrast_boost, console_mse):
#print(image1.shape)
#print(image2.shape)
image1=image1.squeeze(0)
image2=image2.squeeze(0)
diff_image = torch.abs(image1 - image2) #Updated difference calculation to take absolute value; size is more important that directionality here.
#print(diff_image.shape)
if contrast_boost:
boost_factor = 5.0 # can make this a user input later
diff_image = (diff_image * boost_factor).clamp(0,1)
diff_image=diff_image.unsqueeze(0)
#print(diff_image.shape)
# Mean Squared Error
if console_mse:
mse = torch.mean((image1 - image2) ** 2)
mse_percent = mse.item() * 100
mse_percent_formatted = f"{mse_percent:.2f}%"
print(f"\033[1;33mMean Square Error: {mse_percent_formatted}\033[0m")
return diff_image,
NODE_CLASS_MAPPINGS = {
"PWLoraSelector": PWLoraSelector,
"PWLoraNameCollector": PWLoraNameCollector,
"DebugString": DebugString,
"FormatConcatStrings": FormatConcatStrings,
"FormattingSingle": FormattingSingle,
"MosaicEffectNode": MosaicEffectNode,
"FourierAnalysisNode": FourierAnalysisNode,
"TextEmbeddingsInterrogator": TextEmbeddingsInterrogator,
"ImageDifference": ImageDifference,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PWLoraSelector": "Lora Selector",
"PWLoraNameCollector": "Lora Name Collector",
"DebugString": "🔧 Debug String",
"FormatConcatStrings": "Formatting and Concatenating Strings",
"FormattingSingle": "Formatting Single String",
"MosaicEffectNode": "Apply Mosaic Effect",
"FourierAnalysisNode": "Fourier Analysis",
"TextEmbeddingsInterrogator": "Text Embeddings Interrogator",
"ImageDifference": "Image Difference",
}