add more AQnodes

This commit is contained in:
pk
2025-06-06 16:48:04 +02:00
parent 9de499d54a
commit 4918a375b8
6 changed files with 588 additions and 2 deletions
+26 -2
View File
@@ -5,13 +5,28 @@ from .nodes.pulid.AQ_multiface_ApplyPulidFlux import AQ_multiface_ApplyPulidFlux
from .nodes.api.AQ_Gemini import AQ_Gemini
from .nodes.api.AQ_SendImageToAPI import AQ_SendImageToAPI
from .nodes.clip.AQ_CLIPSetLastLayer import AQ_CLIPSetLastLayer
from .nodes.numbers.AQ_Numbers import AQ_Increment, AQ_Random
from .nodes.blend.AQ_BlendImages import AQ_BlendImages
from .nodes.image.AQ_Image import AQ_LoadImageBase64, AQ_Image_Pad, AQ_Image_DetailTransfer, AQ_ImageMaskSwitch
from .nodes.save.AQ_SaveImageWebpReturnBase64 import AQ_SaveImageWebpReturnBase64
NODE_CLASS_MAPPINGS = {
"AQ_MasksAndImagesAsList": AQ_MasksAndImagesAsList,
"AQ_multiface_ApplyPulidFlux": AQ_multiface_ApplyPulidFlux,
"AQ_Qwen": AQ_Qwen,
"AQ_QwenLoader": AQ_QwenLoader,
"AQ_Gemini": AQ_Gemini,
"AQ_SendImageToAPI": AQ_SendImageToAPI
"AQ_SendImageToAPI": AQ_SendImageToAPI,
"AQ_CLIPSetLastLayer": AQ_CLIPSetLastLayer,
"AQ_Increment": AQ_Increment,
"AQ_Random": AQ_Random,
"AQ_BlendImages": AQ_BlendImages,
"AQ_LoadImageBase64": AQ_LoadImageBase64,
"AQ_Image_Pad": AQ_Image_Pad,
"AQ_Image_DetailTransfer": AQ_Image_DetailTransfer,
"AQ_ImageMaskSwitch": AQ_ImageMaskSwitch,
"AQ_SaveImageWebpReturnBase64": AQ_SaveImageWebpReturnBase64
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -20,7 +35,16 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"AQ_Qwen": "AQ_Qwen",
"AQ_QwenLoader": "AQ_QwenLoader",
"AQ_Gemini": "AQ_Gemini",
"AQ_SendImageToAPI": "AQ_SendImageToAPI"
"AQ_SendImageToAPI": "AQ_SendImageToAPI",
"AQ_CLIPSetLastLayer": "AQ_CLIPSetLastLayer",
"AQ_Increment": "AQ_Increment",
"AQ_Random": "AQ_Random",
"AQ_BlendImages": "AQ_BlendImages",
"AQ_LoadImageBase64": "AQ_LoadImageBase64",
"AQ_Image_Pad": "AQ_Image_Pad",
"AQ_Image_DetailTransfer": "AQ_Image_DetailTransfer",
"AQ_ImageMaskSwitch": "AQ_ImageMaskSwitch",
"AQ_SaveImageWebpReturnBase64": "AQ_SaveImageWebpReturnBase64"
}
+179
View File
@@ -0,0 +1,179 @@
import torch
class AQ_BlendImages:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"img1": ("IMAGE",),
"img2": ("IMAGE",),
"device": (["dont_change", "cpu", "cuda"], {"default": "dont_change"},),
"blend_mode": (["Normal", "Dissolve", "Darken", "Multiply", "Color Burn", "Linear Burn", "Darker Color", "Lighten", "Screen", "Color Dodge", "Linear Dodge", "Lighter Color", "Overlay", "Soft Light", "Hard Light", "Vivid Light", "Linear Light", "Pin Light", "Hard Mix", "Difference", "Exclusion", "Subtract", "Divide", "Hue", "Saturation", "Color", "Luminosity"],),
"opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "blend_images"
CATEGORY = "AQ"
def blend_images(self, img1, img2, device, blend_mode, opacity):
# if device differnet than "dont_change"
if device == "dont_change":
device = img1.device
else:
img1 = img1.to(device)
img2 = img2.to(device)
# Ensure images have the same shape
if img1.shape != img2.shape:
raise ValueError("Images must have the same dimensions")
# Separate color and alpha channels
img1, img1_a = img1[..., :3], img1[..., 3:4]
img2, img2_a = img2[..., :3], img2[..., 3:4]
# Helper functions
def lum(img):
return 0.3 * img[..., 0] + 0.59 * img[..., 1] + 0.11 * img[..., 2]
def sat(img):
min_val, _ = torch.min(img, dim=-1)
max_val, _ = torch.max(img, dim=-1)
return max_val - min_val
def set_lum(c, l):
d = l - lum(c)
c = c + d.unsqueeze(-1)
return clip_color(c)
def set_sat(c, s):
c_min, _ = torch.min(c, dim=-1, keepdim=True)
c_max, _ = torch.max(c, dim=-1, keepdim=True)
c_mid = c - c_min
c_mid *= s.unsqueeze(-1) / (c_max - c_min + 1e-6)
return clip_color(c_mid + c_min)
def clip_color(c):
l = lum(c).unsqueeze(-1)
n = torch.min(c, dim=-1, keepdim=True)[0]
x = torch.max(c, dim=-1, keepdim=True)[0]
c_clipped = torch.clamp(c, 0, 1)
c_clipped = torch.where(n < 0, l + ((c - l) * l) / (l - n + 1e-6), c_clipped)
c_clipped = torch.where(x > 1, l + ((c - l) * (1 - l)) / (x - l + 1e-6), c_clipped)
return c_clipped
def hue_blend(img1, img2):
# Calculate luminance and saturation for both images
lum1 = lum(img1)
lum2 = lum(img2)
sat1 = sat(img1)
sat2 = sat(img2)
# Combine the hue from img1 with the saturation and luminance from img2
result = set_lum(set_sat(img1, sat2), lum2)
return result
# Blend mode calculations (only on RGB channels)
if blend_mode == "Normal":
result = img1
elif blend_mode == "Dissolve":
mask = torch.rand_like(img1) < opacity
result = torch.where(mask, img1, img2)
elif blend_mode == "Darken":
result = torch.min(img1, img2)
elif blend_mode == "Multiply":
result = img1 * img2
elif blend_mode == "Color Burn":
result = torch.where(
img1 == 1,
img2, # If top layer (source) is white, no change
torch.where(
img1 > 0,
1 - torch.clamp((1 - img2) / img1, 0, 1),
torch.zeros_like(img2) # If top layer is black, result is black
)
)
elif blend_mode == "Linear Burn":
result = img1 + img2 - 1
elif blend_mode == "Darker Color":
result = torch.where(lum(img1).unsqueeze(-1) < lum(img2).unsqueeze(-1), img1, img2)
elif blend_mode == "Lighten":
result = torch.max(img1, img2)
elif blend_mode == "Screen":
result = 1 - (1 - img1) * (1 - img2)
elif blend_mode == "Color Dodge":
# result = torch.where(img2 < 1, torch.min(torch.ones_like(img1), img1 / (1 - img2)), torch.ones_like(img1))
result = torch.where(img2 < 1, torch.min(torch.ones_like(img1), img1 / (1 - img2)), torch.ones_like(img1))
elif blend_mode == "Linear Dodge":
result = img1 + img2
elif blend_mode == "Lighter Color":
result = torch.where(lum(img1).unsqueeze(-1) > lum(img2).unsqueeze(-1), img1, img2)
elif blend_mode == "Overlay":
result = torch.where(img2 < 0.5, 2 * img1 * img2, 1 - 2 * (1 - img1) * (1 - img2))
elif blend_mode == "Soft Light":
d = torch.where(img2 <= 0.25,
((16 * img2 - 12) * img2 + 4) * img2,
torch.sqrt(img2))
result = torch.where(img1 <= 0.5,
img2 - (1 - 2 * img1) * img2 * (1 - img2),
img2 + (2 * img1 - 1) * (d - img2))
elif blend_mode == "Hard Light":
result = torch.where(img1 <= 0.5, 2 * img1 * img2, 1 - 2 * (1 - img1) * (1 - img2))
elif blend_mode == "Vivid Light":
result = torch.where(img1 < 0.5,
1 - (1 - img2) / (2 * img1 + 1e-8),
img2 / (2 * (1 - img1) + 1e-8))
elif blend_mode == "Linear Light":
result = img2 + 2 * img1 - 1
elif blend_mode == "Pin Light":
result = torch.where(img1 < 0.5,
torch.min(img2, 2 * img1),
torch.max(img2, 2 * img1 - 1))
elif blend_mode == "Hard Mix":
# result = (img1 + img2).round()
# Calculate the sum of the base and blend images
sum_img = img1 + img2
# Apply the Hard Mix formula
result = torch.where(sum_img >= 1, torch.ones_like(img1), torch.zeros_like(img1))
elif blend_mode == "Difference":
result = torch.abs(img1 - img2)
elif blend_mode == "Exclusion":
result = img1 + img2 - 2 * img1 * img2
elif blend_mode == "Subtract":
result = img2 - img1
elif blend_mode == "Divide":
result = img2 / (img1 + 1e-8)
elif blend_mode == "Hue":
result = hue_blend(img1, img2)
elif blend_mode == "Saturation":
result = set_lum(set_sat(img2, sat(img1)), lum(img2))
elif blend_mode == "Color":
result = set_lum(img1, lum(img2))
elif blend_mode == "Luminosity":
result = set_lum(img2, lum(img1))
# self.print_tensor_shape(result, "result before opacity")
# Apply opacity
opacity_tensor = torch.tensor(opacity, device=device).view(1, 1, 1, 1)
# self.print_tensor_shape(opacity_tensor, "opacity_tensor")
result_rgb = img2 * (1 - opacity_tensor) + result * opacity_tensor
# Blend alpha channels
result_a = img1_a * opacity_tensor + img2_a * (1 - opacity_tensor)
# self.print_tensor_shape(result, "final result")
# Combine RGB and alpha channels
result = torch.cat([result_rgb, result_a], dim=-1)
return (result,)
# Add this method to print tensor shapes for debugging
def print_tensor_shape(self, tensor, name):
print(f"{name} shape: {tensor.shape}")
+19
View File
@@ -0,0 +1,19 @@
class AQ_CLIPSetLastLayer:
@classmethod
def INPUT_TYPES(s):
return {"required": { "clip": ("CLIP", ),
"stop_at_clip_layer": ("INT", {"default": 0, "min": -24, "max": 0, "step": 1}),
}}
RETURN_TYPES = ("CLIP",)
FUNCTION = "set_last_layer"
CATEGORY = "AQ/conditioning"
def set_last_layer(self, clip, stop_at_clip_layer):
if(stop_at_clip_layer == 0):
return (clip,)
clip = clip.clone()
clip.clip_layer(stop_at_clip_layer)
return (clip,)
+189
View File
@@ -0,0 +1,189 @@
import torch
import comfy.utils
import comfy.model_management
from torchvision.transforms import Resize, CenterCrop, GaussianBlur
from PIL import Image
import numpy as np
import base64
from io import BytesIO
class AQ_LoadImageBase64:
@classmethod
def INPUT_TYPES(s):
return {"required": {"image": ("STRING", {"multiline": False})}}
RETURN_TYPES = ("IMAGE", "MASK")
CATEGORY = "AQ/Image"
FUNCTION = "load_image"
def load_image(self, image):
imgdata = base64.b64decode(image)
img = Image.open(BytesIO(imgdata))
if "A" in img.getbands():
mask = np.array(img.getchannel("A")).astype(np.float32) / 255.0
mask = 1.0 - torch.from_numpy(mask)
else:
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
img = img.convert("RGB")
img = np.array(img).astype(np.float32) / 255.0
img = torch.from_numpy(img)[None,]
return (img, mask)
class AQ_Image_Pad:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"left": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1}),
"right": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1}),
"top": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1}),
"bottom": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1}),
},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "pad_image"
CATEGORY = "AQ/Image"
def pad_image(self, image, left, right, top, bottom):
# Get the current shape of the image
batch, height, width, channels = image.shape
# Create a new tensor with the padded dimensions
new_height = height + top + bottom
new_width = width + left + right
padded_image = torch.zeros((batch, new_height, new_width, channels), dtype=image.dtype, device=image.device)
# Set the alpha channel to 0 (fully transparent) for the entire padded image
padded_image[:, :, :, 3] = 0
# Copy the original image into the padded image
padded_image[:, top:top+height, left:left+width, :] = image
return (padded_image,)
class AQ_Image_DetailTransfer:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"target": ("IMAGE",),
"source": ("IMAGE",),
"mode": (["add", "multiply", "screen", "overlay", "soft_light", "hard_light", "color_dodge", "color_burn", "difference", "exclusion", "divide",],{"default": "add"}),
"blur_sigma": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 100.0, "step": 0.01}),
"blend_factor": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.001, "round": 0.001}),
"image_output": (["Hide", "Preview", "Save", "Hide/Save"], {"default": "Preview"}),
"save_prefix": ("STRING", {"default": "ComfyUI"}),
},
"optional": {
"mask": ("MASK",),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
OUTPUT_NODE = False
FUNCTION = "transfer"
CATEGORY = "AQ/Image"
def transfer(self, target, source, mode, blur_sigma, blend_factor, image_output, save_prefix, mask=None, prompt=None, extra_pnginfo=None):
batch_size, height, width, _ = target.shape
device = comfy.model_management.get_torch_device()
target_tensor = target.permute(0, 3, 1, 2).clone().to(device)
source_tensor = source.permute(0, 3, 1, 2).clone().to(device)
if target.shape[1:] != source.shape[1:]:
source_tensor = comfy.utils.common_upscale(source_tensor, width, height, "bilinear", "disabled")
if source.shape[0] < batch_size:
source = source[0].unsqueeze(0).repeat(batch_size, 1, 1, 1)
kernel_size = int(6 * int(blur_sigma) + 1)
gaussian_blur = GaussianBlur(kernel_size=(kernel_size, kernel_size), sigma=(blur_sigma, blur_sigma))
blurred_target = gaussian_blur(target_tensor)
blurred_source = gaussian_blur(source_tensor)
if mode == "add":
new_image = (source_tensor - blurred_source) + blurred_target
elif mode == "multiply":
new_image = source_tensor * blurred_target
elif mode == "screen":
new_image = 1 - (1 - source_tensor) * (1 - blurred_target)
elif mode == "overlay":
new_image = torch.where(blurred_target < 0.5, 2 * source_tensor * blurred_target,
1 - 2 * (1 - source_tensor) * (1 - blurred_target))
elif mode == "soft_light":
new_image = (1 - 2 * blurred_target) * source_tensor ** 2 + 2 * blurred_target * source_tensor
elif mode == "hard_light":
new_image = torch.where(source_tensor < 0.5, 2 * source_tensor * blurred_target,
1 - 2 * (1 - source_tensor) * (1 - blurred_target))
elif mode == "difference":
new_image = torch.abs(blurred_target - source_tensor)
elif mode == "exclusion":
new_image = 0.5 - 2 * (blurred_target - 0.5) * (source_tensor - 0.5)
elif mode == "color_dodge":
new_image = blurred_target / (1 - source_tensor)
elif mode == "color_burn":
new_image = 1 - (1 - blurred_target) / source_tensor
elif mode == "divide":
new_image = (source_tensor / blurred_source) * blurred_target
else:
new_image = source_tensor
new_image = torch.lerp(target_tensor, new_image, blend_factor)
if mask is not None:
mask = mask.to(device)
new_image = torch.lerp(target_tensor, new_image, mask)
new_image = torch.clamp(new_image, 0, 1)
new_image = new_image.permute(0, 2, 3, 1).cpu().float()
return {"ui": {},
"result": (new_image,)}
class AQ_ImageMaskSwitch:
@classmethod
def INPUT_TYPES(s):
return {"required": {
"select": ("INT", {"default": 1, "min": 1, "max": 4, "step": 1}),
"images1": ("IMAGE",),
},
"optional": {
"mask1_opt": ("MASK",),
"images2_opt": ("IMAGE",),
"mask2_opt": ("MASK",),
"images3_opt": ("IMAGE",),
"mask3_opt": ("MASK",),
"images4_opt": ("IMAGE",),
"mask4_opt": ("MASK",),
},
}
RETURN_TYPES = ("IMAGE", "MASK",)
OUTPUT_NODE = False
FUNCTION = "doit"
CATEGORY = "AQ/Image"
def doit(self, select, images1, mask1_opt=None, images2_opt=None, mask2_opt=None, images3_opt=None, mask3_opt=None,
images4_opt=None, mask4_opt=None):
if select == 1:
return images1, mask1_opt,
elif select == 2:
return images2_opt, mask2_opt,
elif select == 3:
return images3_opt, mask3_opt,
else:
return images4_opt, mask4_opt,
+44
View File
@@ -0,0 +1,44 @@
import random
class AQ_Random:
@classmethod
def INPUT_TYPES(s):
return {"required": {
"initial": ("INT", {"default": 0, "min": 0, "step": 1}),
}}
RETURN_TYPES = ("INT",)
FUNCTION = "generateRandomNumber"
CATEGORY = "AQ/numbers"
def generateRandomNumber(self, initial):
randNumber = random.randint(0, 0xffffffffffffffff)
return (randNumber,)
class AQ_Increment:
@classmethod
def INPUT_TYPES(s):
return {"required": {
"initial": ("INT", {"default": 0, "min": 0, "step": 1}),
"force_initial": ("BOOLEAN", {"default": False}),
}}
RETURN_TYPES = ("INT",)
FUNCTION = "incrementNumber"
CATEGORY = "AQ/numbers"
numberFromPrevExecution = -1
def incrementNumber(self, initial, force_initial):
if(AQ_Increment.numberFromPrevExecution == -1 or force_initial):
AQ_Increment.numberFromPrevExecution = initial
return (initial,)
currentState = AQ_Increment.numberFromPrevExecution
AQ_Increment.numberFromPrevExecution += 1
return (currentState,)
+131
View File
@@ -0,0 +1,131 @@
import os
import folder_paths as comfy_paths
import torch
from PIL import Image, ImageFilter, ImageEnhance, ImageOps, ImageDraw, ImageChops, ImageFont
import numpy as np
import torch.nn.functional as F
from server import PromptServer
import piexif
import piexif.helper
import json
import requests
import io
import base64
MODELS_DIR = comfy_paths.models_dir
AQ_IMAGES_OUTPUT_DIR = comfy_paths.get_output_directory()
class AQ_SaveImageWebpReturnBase64:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE",),
"name": ("STRING", {"default": "image"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
"prompt_text": ("STRING", {"default": ""}),
"cfg": ("FLOAT", { "default": 0, "min": 0.0, "max": 50.0 }),
"steps": ("INT", {"default": 0}),
"scheduler": ("STRING", {"default": ""}),
"sampler": ("STRING", {"default": ""}),
},
"hidden": {"unique_id": "UNIQUE_ID",
"extra_pnginfo": "EXTRA_PNGINFO",
"prompt": "PROMPT"
},
}
RETURN_TYPES = ()
FUNCTION = "save_images"
OUTPUT_NODE = True
CATEGORY = "AQ/images"
def save_images(self, images, name, seed, prompt_text, cfg, steps, scheduler, sampler, unique_id, extra_pnginfo, prompt):
import random
is_dev = os.environ.get("IS_DEV", False)
resultsDev = []
results = []
index = 0
server = PromptServer.instance
#subfolder = os.path.normpath(os.path.join('user_', server.client_id, server.last_prompt_id))
subfolder = os.path.normpath(os.path.join('user_', server.client_id))
if not os.path.exists(AQ_IMAGES_OUTPUT_DIR):
os.makedirs(AQ_IMAGES_OUTPUT_DIR)
out_dir = os.path.join(AQ_IMAGES_OUTPUT_DIR, subfolder)
for tensor in images:
index += 1
name_with_index = f"{name}_{index}_{seed}_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5)) + '.webp'
array = 255.0 * tensor.cpu().numpy()
image = Image.fromarray(np.clip(array, 0, 255).astype(np.uint8))
import io
import base64
buffer = io.BytesIO()
userdata = {
'prompt': prompt_text,
'cfg': cfg,
'steps': steps,
'scheduler': scheduler,
'sampler': sampler,
'seed': seed,
}
exifData = piexif.helper.UserComment.dump(
json.dumps(userdata),
encoding="unicode"
)
# Create EXIF dictionary
exif_dict = {"Exif": {piexif.ExifIFD.UserComment: exifData}}
# Convert the EXIF dictionary to bytes
exif_bytes = piexif.dump(exif_dict)
image.save(buffer, format="WEBP")
webp_image_data = buffer.getvalue()
webp_base64 = base64.b64encode(webp_image_data).decode('utf-8')
full_output_path = f"{out_dir}/{name_with_index}"
if os.path.commonpath((AQ_IMAGES_OUTPUT_DIR, os.path.abspath(full_output_path))) != AQ_IMAGES_OUTPUT_DIR:
print("Saving image outside the output folder is not allowed.")
return {}
if not os.path.exists(out_dir):
os.makedirs(out_dir)
image.save(full_output_path, format="WEBP", exif=exif_bytes)
full_output_path_txt = full_output_path.replace(".webp", ".txt")
with open(full_output_path_txt, "w") as f:
f.write(prompt_text)
if is_dev and extra_pnginfo is not None:
resultsDev.append({
"filename": name_with_index + " [output]",
"subfolder": out_dir,
"type": "base64Image",
"name": name_with_index
})
return { "ui": { "images": resultsDev } }
results.append(
{
"type": "base64Image",
"name": name_with_index,
"data": webp_base64,
"seed": seed,
}
)
return ({"ui": {"images": results}})