Files
Pirog17000 dce5abee01 Enhance node functionality and update prompt dictionary
- Added new nodes: GetImageSize, BlendImages, LMStudioQuery, LMStudioUnloadModel, CLIPTextEncodeMultiple, and CLIPTextEncodeFluxMultiple for improved image processing and AI integration.
- Updated ImageScalePro to return width and height alongside the scaled image.
- Enhanced PromptRandomizer to support multiple prompt generation.
- Refined README to include new node descriptions and installation instructions.
- Updated prompt_dictionary.json to streamline categories and terms for randomization.
2025-11-17 01:33:12 +01:00

2665 lines
108 KiB
Python

"""
Pirog's Nodes Implementation
A custom node pack for ComfyUI that provides enhanced sampling functionality.
Author: pirog
"""
import os
import sys
import torch
import json
import random
import re
import logging
import importlib.util
import numpy as np
from PIL import Image, ImageChops, ImageOps
from scipy.ndimage import gaussian_filter
import requests
import base64
from io import BytesIO
from urllib.parse import urlparse
from .utilities import tensor_to_pil, pil_to_tensor, srgb_to_linear, linear_to_srgb, apply_noise_blur, tensor_to_numpy, numpy_to_tensor, safe_poisson
# Try to import LM Studio SDK for model unloading
try:
import lmstudio
LMSTUDIO_SDK_AVAILABLE = True
except ImportError:
LMSTUDIO_SDK_AVAILABLE = False
print("LM Studio SDK not available. Model unloading will not work. Install with: pip install lmstudio")
logger = logging.getLogger(__name__)
# Add the ComfyUI root directory to Python path to access the main nodes
comfy_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if comfy_dir not in sys.path:
sys.path.insert(0, comfy_dir)
# Import the common_ksampler function, MAX_RESOLUTION, and SaveImage directly from the main nodes.py
# This ensures we always use the latest version from ComfyUI
try:
from nodes import common_ksampler, MAX_RESOLUTION, SaveImage
except ImportError:
# Fallback: try to import from nodes module in current directory
spec = importlib.util.spec_from_file_location("nodes", os.path.join(comfy_dir, "nodes.py"))
nodes_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(nodes_module)
common_ksampler = nodes_module.common_ksampler
MAX_RESOLUTION = nodes_module.MAX_RESOLUTION
SaveImage = nodes_module.SaveImage
# Import required ComfyUI modules
# import comfy.samplers
from comfy.comfy_types import IO, ComfyNodeABC #, InputTypeDict
try:
import folder_paths
from PIL.PngImagePlugin import PngInfo
from comfy.cli_args import args
except ImportError:
# These will be available at runtime in ComfyUI
folder_paths = None
PngInfo = None
args = None
class StringCombine:
"""
Text Concatenation Node with Fancy Boolean Toggle
This node provides two multiline text input fields and a boolean toggle
to control whether to concatenate both strings or output only the first one.
Features a sleek toggle button following KJ/Essentials styling patterns.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text1": ("STRING", {
"multiline": True,
"default": "First text...",
"tooltip": "Primary text input. Always included in output."
}),
"text2": ("STRING", {
"multiline": True,
"default": "Second text...",
"tooltip": "Secondary text input. Concatenated when toggle is enabled."
}),
"concat_enabled": ("BOOLEAN", {
"default": True,
"tooltip": "Enable to concatenate both texts. Disable to output only first text."
}),
}
}
RETURN_TYPES = ("STRING",)
OUTPUT_TOOLTIPS = ("The concatenated or single text output based on toggle state.",)
FUNCTION = "concatenate_text"
CATEGORY = "pirog/text"
DESCRIPTION = "Concatenates two multiline text inputs based on a boolean toggle. When enabled, outputs text1 + text2. When disabled, outputs only text1."
def concatenate_text(self, text1, text2, concat_enabled):
if concat_enabled:
# Concatenate both texts
result = text1 + text2
else:
# Output only the first text
result = text1
return (result,)
class Watermark:
"""
Advanced Watermark Node
Applies watermarks to images with multiple blend modes, positioning options,
and advanced masking capabilities. Supports batch processing with proper
error handling and logging.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE", {"tooltip": "Base image(s) to apply watermark to"}),
"watermark": ("IMAGE", {"tooltip": "Watermark image to overlay"}),
"scale": ("FLOAT", {
"default": 1.0,
"min": 0.001,
"max": 1.0,
"step": 0.001,
"tooltip": "Scale factor for watermark size (0.001-1.0)"
}),
"opacity": ("FLOAT", {
"default": 0.5,
"min": 0.001,
"max": 1.0,
"step": 0.001,
"tooltip": "Watermark opacity (0.001-1.0)"
}),
"blend_mode": ([
"normal", "overlay", "soft_light", "hard_light", "difference",
"exclusion", "darken", "lighten", "color_burn", "color_dodge", "add"
], {"tooltip": "Blending mode for watermark application"}),
"position": ([
"random", "center", "top-left", "top-center", "top-right",
"middle-left", "middle-right", "bottom-left", "bottom-center", "bottom-right"
], {"tooltip": "Watermark positioning on the image"}),
"invert_watermark": ("BOOLEAN", {
"default": False,
"tooltip": "Invert watermark colors before applying"
}),
"use_black_mask": ("BOOLEAN", {
"default": False,
"tooltip": "Use black pixels as transparent mask"
}),
}
}
RETURN_TYPES = ("IMAGE",)
OUTPUT_TOOLTIPS = ("Image(s) with watermark applied",)
FUNCTION = "apply_watermark"
CATEGORY = "pirog/image"
DESCRIPTION = "Applies watermarks to images with advanced blending modes, positioning, and masking options."
def apply_watermark(self, image, watermark, scale, opacity, blend_mode, position, invert_watermark, use_black_mask):
logger = logging.getLogger(__name__)
try:
logger.info(f"Input image tensor shape: {image.shape}")
logger.info(f"Input watermark tensor shape: {watermark.shape}")
# Ensure tensors are in BHWC format (ComfyUI standard)
if image.dim() == 3:
image = image.unsqueeze(0)
# ComfyUI tensors are already in BHWC format, no permute needed
if watermark.dim() == 3:
watermark = watermark.unsqueeze(0)
# ComfyUI tensors are already in BHWC format, no permute needed
batch_size, height, width, channels = image.shape
processed_images = []
for i in range(batch_size):
img_pil = tensor_to_pil(image[i])
watermark_pil = tensor_to_pil(watermark[0] if watermark.shape[0] > 1 else watermark)
result = self.process_single_image(img_pil, watermark_pil, scale, opacity, blend_mode, position, invert_watermark, use_black_mask, logger)
processed_images.append(pil_to_tensor(result))
# Concatenate processed images along the batch dimension
result = torch.cat(processed_images, dim=0)
# Result is already in BHWC format as expected by ComfyUI
return (result,)
except Exception as e:
logger.error(f"Error in watermark application: {str(e)}")
return (image,) # Return original image in case of error
def process_single_image(self, image, watermark, scale, opacity, blend_mode, position, invert_watermark, use_black_mask, logger):
watermark, mask = self.prepare_watermark(watermark, scale, invert_watermark, use_black_mask, logger)
pos = self.get_watermark_position(position, image.size, watermark.size)
if image.mode != 'RGBA':
image = image.convert('RGBA')
watermark_layer = Image.new('RGBA', image.size, (0, 0, 0, 0))
watermark_layer.paste(watermark, pos, watermark)
blended = self.apply_blend_mode(image, watermark_layer, blend_mode, logger)
# Combine the black pixel mask with the alpha channel mask
if use_black_mask and mask:
mask_layer = Image.new('L', image.size, 0)
mask_layer.paste(mask, pos)
combined_mask = ImageChops.multiply(watermark_layer.split()[3], mask_layer)
else:
combined_mask = watermark_layer.split()[3]
# Convert opacity to integer value
opacity_int = self.float_to_int(opacity)
# Use the combined mask to blend between the original image and the blended result
result = Image.composite(blended, image, ImageChops.multiply(combined_mask, Image.new('L', combined_mask.size, opacity_int)))
logger.info(f"Watermark applied at position {pos} with opacity {opacity} (int: {opacity_int}), "
f"blend mode {blend_mode}, and use_black_mask={use_black_mask}")
return result
def apply_blend_mode(self, base, blend, mode, logger):
if mode == "normal":
return blend
elif mode == "overlay":
return ImageChops.overlay(base, blend)
elif mode == "soft_light":
return ImageChops.soft_light(base, blend)
elif mode == "hard_light":
return ImageChops.hard_light(base, blend)
elif mode == "difference":
return ImageChops.difference(base, blend)
elif mode == "exclusion":
return ImageChops.invert(ImageChops.difference(ImageChops.invert(base), blend))
elif mode == "darken":
return ImageChops.darker(base, blend)
elif mode == "lighten":
return ImageChops.lighter(base, blend)
elif mode == "color_burn":
return ImageChops.subtract(base, ImageChops.invert(blend))
elif mode == "color_dodge":
return ImageChops.invert(ImageChops.subtract(ImageChops.invert(base), blend))
elif mode == "add":
return ImageChops.add(base, blend, scale=1.0, offset=0)
else:
logger.warning(f"Unknown blend mode: {mode}. Falling back to normal blend.")
return blend
def prepare_watermark(self, watermark, scale, invert, use_black_mask, logger):
# Resize the watermark
w, h = watermark.size
new_w, new_h = int(w * scale), int(h * scale)
watermark = watermark.resize((new_w, new_h), Image.LANCZOS)
# Invert the watermark if requested
if invert:
watermark = ImageOps.invert(watermark)
# Ensure the watermark is in RGBA mode
if watermark.mode != 'RGBA':
watermark = watermark.convert('RGBA')
# Create mask from black pixels after possible inversion
if use_black_mask:
# Convert to grayscale
gray_watermark = watermark.convert('L')
# Create mask: white for non-black pixels, black for black pixels
# Using a small threshold (5) to account for near-black pixels
mask = gray_watermark.point(lambda x: 255 if x > 5 else 0, mode='1')
# Log the number of pixels in the mask for debugging
black_pixel_count = sum(1 for pixel in mask.getdata() if pixel == 0)
logger.info(f"Black pixel mask created with {black_pixel_count} black pixels")
else:
mask = None
return watermark, mask
def get_watermark_position(self, position, image_size, watermark_size):
if position == "center":
return ((image_size[0] - watermark_size[0]) // 2,
(image_size[1] - watermark_size[1]) // 2)
elif position == "top-left":
return (0, 0)
elif position == "top-center":
return ((image_size[0] - watermark_size[0]) // 2, 0)
elif position == "top-right":
return (image_size[0] - watermark_size[0], 0)
elif position == "middle-left":
return (0, (image_size[1] - watermark_size[1]) // 2)
elif position == "middle-right":
return (image_size[0] - watermark_size[0], (image_size[1] - watermark_size[1]) // 2)
elif position == "bottom-left":
return (0, image_size[1] - watermark_size[1])
elif position == "bottom-center":
return ((image_size[0] - watermark_size[0]) // 2, image_size[1] - watermark_size[1])
elif position == "bottom-right":
return (image_size[0] - watermark_size[0],
image_size[1] - watermark_size[1])
else: # random
return (np.random.randint(0, max(1, image_size[0] - watermark_size[0])),
np.random.randint(0, max(1, image_size[1] - watermark_size[1])))
def float_to_int(self, value):
"""Convert float (0-1) to int (0-255)"""
return int(value * 255)
class ImageScalePro:
"""
Image Scaling Tool
Advanced proportional image scaling with resolution limits, step alignment,
and professional-grade controls. Features MX-style slider compatibility
for enhanced user experience. Includes selectable resampling methods with an
automatic mode that chooses the best filter for upscaling or downscaling.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE", {"tooltip": "Input image(s) to scale"}),
"scale_multiplier": ("FLOAT", {
"default": 1.0,
"min": 0.1,
"max": 10.0,
"step": 0.01,
"tooltip": "Scale factor - 1.0 = original size, 2.0 = double size, 0.5 = half size"
}),
"resampling_method": (["auto", "LANCZOS", "NEAREST", "BILINEAR", "BICUBIC"], {
"default": "auto",
"tooltip": "Resampling method. 'auto' selects NEAREST for upscaling, LANCZOS for downscaling."
}),
"resolution_step": ("INT", {
"default": 8,
"min": 1,
"max": 64,
"step": 1,
"tooltip": "Resolution alignment step (dimensions will be rounded to multiples of this value)"
}),
"enable_limits": ("BOOLEAN", {
"default": True,
"tooltip": "Enable resolution limits to prevent extremely large or small images"
}),
"min_resolution": ("INT", {
"default": 256,
"min": 64,
"max": 4096,
"step": 8,
"tooltip": "Minimum resolution for the longest side when limits are enabled"
}),
"max_resolution": ("INT", {
"default": 2560,
"min": 64,
"max": 8192,
"step": 8,
"tooltip": "Maximum resolution for the longest side when limits are enabled"
})
}
}
RETURN_TYPES = ("IMAGE", "INT", "INT")
RETURN_NAMES = ("scaled_image", "width", "height")
OUTPUT_TOOLTIPS = ("Scaled image(s) with proper aspect ratio preservation", "Width of scaled image", "Height of scaled image")
FUNCTION = "scale_image"
CATEGORY = "pirog/transform"
DESCRIPTION = "Professional image scaling with proportional resize, resolution limits, step alignment, and selectable resampling methods for optimal results."
def scale_image(self, image, scale_multiplier, resampling_method, resolution_step, enable_limits, min_resolution, max_resolution):
logger = logging.getLogger(__name__)
try:
logger.info(f"Input tensor shape: {image.shape}, dtype: {image.dtype}")
batch_size, height, width, channels = image.shape
scaled_images = []
# Map string names to PIL resampling filters
resample_filters = {
"LANCZOS": Image.LANCZOS,
"NEAREST": Image.NEAREST,
"BILINEAR": Image.BILINEAR,
"BICUBIC": Image.BICUBIC,
}
for i in range(batch_size):
pil_image = tensor_to_pil(image[i])
logger.info(f"Original PIL Image size: {pil_image.size}")
new_width = int(width * scale_multiplier)
new_height = int(height * scale_multiplier)
if enable_limits:
new_width, new_height = self.apply_resolution_limits(new_width, new_height, min_resolution, max_resolution)
new_width = max((new_width // resolution_step) * resolution_step, resolution_step)
new_height = max((new_height // resolution_step) * resolution_step, resolution_step)
logger.info(f"Calculated new dimensions: {new_width}x{new_height}")
# Determine resampling method
if resampling_method == "auto":
# Downscaling: LANCZOS is better. Upscaling: NEAREST is sharp and fast.
resample_filter = Image.LANCZOS if scale_multiplier < 1.0 else Image.NEAREST
else:
resample_filter = resample_filters.get(resampling_method, Image.LANCZOS)
logger.info(f"Using resampling method: {resampling_method} -> {resample_filter}")
resized_image = pil_image.resize((new_width, new_height), resample_filter)
logger.info(f"Resized PIL Image size: {resized_image.size}")
scaled_images.append(pil_to_tensor(resized_image))
output_tensor = torch.cat(scaled_images, dim=0)
logger.info(f"Output tensor shape: {output_tensor.shape}")
return (output_tensor, new_width, new_height)
except Exception as e:
logger.error(f"Error in scale_image: {str(e)}")
logger.error(f"Input tensor shape: {image.shape}, dtype: {image.dtype}")
return (image, width, height)
def apply_resolution_limits(self, width, height, min_resolution, max_resolution):
longest_side = max(width, height)
scale_factor = 1.0
if longest_side < min_resolution:
scale_factor = min_resolution / longest_side
elif longest_side > max_resolution:
scale_factor = max_resolution / longest_side
new_width = int(width * scale_factor)
new_height = int(height * scale_factor)
return new_width, new_height
class GetImageSize:
"""
Get Image Dimensions
Extracts width and height dimensions from an image tensor.
Returns integer values for width and height.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE", {"tooltip": "Input image to get dimensions from"})
}
}
RETURN_TYPES = ("INT", "INT")
RETURN_NAMES = ("width", "height")
OUTPUT_TOOLTIPS = ("Width of the image", "Height of the image")
FUNCTION = "get_size"
CATEGORY = "pirog/utility"
DESCRIPTION = "Extract width and height dimensions from an image."
def get_size(self, image):
"""
Extract width and height from image tensor.
Args:
image: Image tensor in ComfyUI format, can be BHWC or HWC
Returns:
tuple: (width, height) as integers
"""
# Image tensor shape is either (batch_size, height, width, channels) or (height, width, channels).
# We can reliably get height and width from the second and third last dimensions.
height = image.shape[-3]
width = image.shape[-2]
return (width, height)
class PromptRandomizer:
"""
Advanced Prompt Randomization Tool
Randomizes text prompts using pattern replacement with dictionary support.
Features relative path handling, validation, fallback dictionaries, and
configurable text processing options.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"prompt": ("STRING", {
"multiline": True,
"default": "?color? ?dog|cat? with ?orange|black? dots on the ?couch|floor?",
"tooltip": "Use pattern ?word1|word2|metaword? to randomize. Encapsulate groups with question marks. Meta-words are looked up in the dictionary."
}),
"dictionary_filename": ("STRING", {
"default": "prompt_dictionary.json",
"tooltip": "Dictionary filename (relative to this node pack). Will create a default if not found."
}),
"seed": ("INT", {
"default": -1,
"min": -1,
"max": 0xffffffffffffffff,
"tooltip": "Set to -1 for random seed each run, or any positive number for reproducible results."
}),
"preserve_newlines": ("BOOLEAN", {
"default": False,
"tooltip": "Keep newlines in output. When disabled, newlines are converted to commas."
}),
"clean_text": ("BOOLEAN", {
"default": True,
"tooltip": "Apply text cleaning (remove extra spaces, fix punctuation, convert anime terms)."
}),
"prompts_count": ("INT", {
"default": 1, "min": 1, "max": 100, "step": 1,
"tooltip": "Number of randomized prompts to generate. Useful for batch processing with samplers that support multiple prompts."
})
}
}
RETURN_TYPES = ("STRING", "STRING", "INT")
RETURN_NAMES = ("modified_prompts", "selections", "used_seed")
FUNCTION = "randomize_prompt"
CATEGORY = "pirog/text"
DESCRIPTION = "Randomizes text prompts using pattern replacement with dictionary support, validation, and flexible text processing options. Can generate multiple prompts for batch processing."
def randomize_prompt(self, prompt, dictionary_filename, seed, preserve_newlines, clean_text, prompts_count):
# Handle input processing - convert arrays to strings if needed
if isinstance(prompt, list):
prompt = " ".join(str(x) for x in prompt)
# Get dictionary path relative to this node pack
dictionary_path = self.get_dictionary_path(dictionary_filename)
# Load or create the dictionary
random_dictionary = self.load_or_create_dictionary(dictionary_path)
generated_prompts = []
all_selections_list = []
# Generate multiple prompts with different seeds
for prompt_idx in range(prompts_count):
# Set the seed for this prompt (increment from base seed)
current_seed = seed + prompt_idx if seed != -1 else random.randint(0, 0xffffffffffffffff) + prompt_idx
random.seed(current_seed)
# Work on a copy of the original prompt
current_prompt = prompt
current_selections = []
# Process randomization for this prompt
for i in range(10): # Max 10 iterations to prevent infinite loops
# Find all tags in the prompt string
tags = re.findall(r"\?\s*[^\?]+\s*\?", current_prompt)
if not tags: # No more tags to process
break
for tag in tags:
# Get the options
options = tag[1:-1].split("|")
options = [option.strip() for option in options]
# Choose a random option
random_option = random.choice(options)
# If the option is in the dictionary, use a random word from that category
# Otherwise, use the option as-is
if random_option in random_dictionary and random_dictionary[random_option]:
word = random.choice(random_dictionary[random_option])
else:
word = random_option
# Replace the tag with the chosen word
current_prompt = current_prompt.replace(tag, word, 1)
# Add only non-empty selections to array for later display
if word and word.strip():
current_selections.append(word.strip())
# Apply text processing based on settings
if clean_text:
current_prompt = self.clean_prompt(current_prompt)
if not preserve_newlines:
current_prompt = self.remove_newlines(current_prompt)
generated_prompts.append(current_prompt)
all_selections_list.append(", ".join(current_selections))
# Use the first seed as the "used_seed" for compatibility
used_seed = seed if seed != -1 else random.randint(0, 0xffffffffffffffff)
# Return single prompt/string when count=1, list when count>1
if prompts_count == 1:
return (generated_prompts[0], all_selections_list[0], used_seed)
else:
return (generated_prompts, all_selections_list, used_seed)
def get_dictionary_path(self, filename):
# Get the directory where this node pack is located
script_directory = os.path.dirname(os.path.abspath(__file__))
# Ensure filename is just a filename, not a path
filename = os.path.basename(filename)
# Create full path
dictionary_path = os.path.join(script_directory, filename)
return dictionary_path
def load_or_create_dictionary(self, dictionary_path):
try:
with open(dictionary_path, 'r', encoding='utf-8') as dict_file:
random_dictionary = json.load(dict_file)
return random_dictionary
except FileNotFoundError:
# Create a default dictionary
default_dict = self.create_default_dictionary()
try:
with open(dictionary_path, 'w', encoding='utf-8') as dict_file:
json.dump(default_dict, dict_file, indent=2, ensure_ascii=False)
logging.info(f"Created default dictionary at: {dictionary_path}")
except Exception as e:
logging.warning(f"Could not create dictionary file: {e}")
return default_dict
except json.JSONDecodeError as e:
logging.warning(f"Invalid JSON in dictionary file: {dictionary_path}. Using default dictionary. Error: {e}")
return self.create_default_dictionary()
except Exception as e:
logging.error(f"Error reading dictionary file: {dictionary_path}. Using default dictionary. Error: {e}")
return self.create_default_dictionary()
def create_default_dictionary(self):
"""Create a default dictionary with common prompt categories"""
return {
"color": ["red", "blue", "green", "yellow", "purple", "orange", "pink", "black", "white", "gray"],
"animal": ["cat", "dog", "bird", "rabbit", "fox", "wolf", "lion", "tiger", "bear", "deer"],
"style": ["photorealistic", "artistic", "anime", "cartoon", "realistic", "stylized", "abstract"],
"lighting": ["soft lighting", "dramatic lighting", "natural lighting", "studio lighting", "golden hour"],
"mood": ["happy", "serene", "mysterious", "energetic", "calm", "dramatic", "peaceful", "intense"],
"quality": ["high quality", "masterpiece", "detailed", "sharp", "crisp", "professional", "stunning"],
"background": ["forest", "city", "beach", "mountains", "garden", "studio", "indoor", "outdoor"],
"weather": ["sunny", "cloudy", "rainy", "foggy", "snowy", "stormy", "clear", "overcast"]
}
def clean_prompt(self, string_to_fix):
"""Clean and normalize the prompt text"""
fixed = string_to_fix
# Multiple iterations to catch nested issues
for i in range(5):
# Normalize whitespace
fixed = fixed.replace('\n', ', ')
fixed = re.sub(r'\s+', ' ', fixed) # Replace multiple spaces with single space
# Fix punctuation
fixed = fixed.replace(' , ,', ',')
fixed = fixed.replace(' ,', ',')
fixed = fixed.replace(' ,;', ';')
fixed = fixed.replace(';;', ';')
fixed = fixed.replace('; ;', ';')
fixed = fixed.replace(';,', '; ')
fixed = fixed.replace('; ,', ';')
fixed = fixed.replace(', ;', ';')
fixed = fixed.replace(' ,, ', ', ')
fixed = fixed.replace(', ,', ', ')
fixed = fixed.replace(',,', ',')
fixed = fixed.replace(', , , ', ', ')
fixed = fixed.replace(', , ,', ', ')
# Remove empty brackets
fixed = fixed.replace('[]', '')
fixed = fixed.replace('()', '')
fixed = fixed.replace('(()', '')
# Anime/manga term normalization (optional - can be disabled)
fixed = fixed.replace('1girl', 'one woman')
fixed = fixed.replace('2girls', 'two women')
fixed = fixed.replace('3girls', 'three women')
fixed = fixed.replace('1boy', 'one man')
fixed = fixed.replace('2boys', 'two men')
fixed = fixed.replace('3boys', 'three men')
return fixed.strip()
def remove_newlines(self, text):
"""Convert newlines to commas for single-line output"""
# Replace newlines with commas, but avoid double commas
text = text.replace('\n', ', ')
text = re.sub(r',\s*,', ',', text) # Remove double commas
text = re.sub(r',\s*$', '', text) # Remove trailing comma
return text.strip()
# --- Camera Sensor Profiles ---
# Based on research into camera sensor performance. These profiles dictate the
# physical characteristics of the simulated sensor.
CAMERA_PROFILES = {
"Pro Full-Frame": {
"read_noise_base_e": 1.5, # Electrons of read noise at base ISO (e.g., ISO 100)
"read_noise_iso_scaling": 0.15,# How much read noise increases with analog gain
"dark_current_base": 0.1, # Electrons/sec at 20°C - a measure of thermal noise
"prnu_factor": 0.004, # Photo Response Non-Uniformity (multiplicative noise)
"dsnu_factor": 0.002, # Dark Signal Non-Uniformity (additive fixed pattern noise)
"quantization_levels": 16384, # 14-bit ADC (Analog-to-Digital Converter)
"full_well_capacity": 90000, # Maximum electrons a pixel can hold
"chroma_noise_factor": 0.4, # Balance between luminance and chrominance noise
},
"APS-C Enthusiast": {
"read_noise_base_e": 2.5,
"read_noise_iso_scaling": 0.25,
"dark_current_base": 1.5,
"prnu_factor": 0.007,
"dsnu_factor": 0.005,
"quantization_levels": 4096, # 12-bit ADC
"full_well_capacity": 60000,
"chroma_noise_factor": 0.5,
},
"Smartphone": {
"read_noise_base_e": 4.0,
"read_noise_iso_scaling": 0.35,
"dark_current_base": 1.2,
"prnu_factor": 0.01,
"dsnu_factor": 0.008,
"quantization_levels": 1024, # 10-bit ADC
"full_well_capacity": 15000,
"chroma_noise_factor": 0.6,
},
"Vintage DSLR": {
"read_noise_base_e": 5.5,
"read_noise_iso_scaling": 0.4,
"dark_current_base": 4.5,
"prnu_factor": 0.012,
"dsnu_factor": 0.01,
"quantization_levels": 4096, # 12-bit ADC
"full_well_capacity": 45000,
"chroma_noise_factor": 0.7,
}
}
class DSLRNoise(ComfyNodeABC):
"""
DSLR Camera Noise Simulation Node
Scientifically accurate camera sensor noise simulation based on real DSLR noise research.
Implements the Poisson-Gaussian noise model with shot noise, read noise, thermal noise,
and PRNU (Pixel Response Non-Uniformity) as described in camera sensor literature.
References:
- "Noise, Dynamic Range and Bit Depth in Digital SLRs" (Emil Martinec)
- "Digital Camera Image Noise" (Cambridge in Colour)
- ISO 15739 noise measurement standards
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE", {
"tooltip": "The input image to apply noise to."
}),
"iso": ("INT", {
"default": 800, "min": 50, "max": 12800, "step": 50,
"tooltip": "Higher ISO increases noise, simulating higher light sensitivity."
}),
"profile": (list(CAMERA_PROFILES.keys()), {
"tooltip": "Select a camera sensor profile. Each profile has distinct noise characteristics."
}),
"temperature": ("FLOAT", {
"default": 25.0, "min": -20.0, "max": 60.0, "step": 1.0,
"tooltip": "Sensor temperature (Celsius). Higher temperatures increase thermal noise."
}),
"exposure_time": ("FLOAT", {
"default": 1/125.0, "min": 0.0, "max": 30.0, "step": 0.001,
"tooltip": "Shutter speed (seconds). Longer exposures increase thermal noise and affect shot noise."
}),
"overall_strength": ("FLOAT", {
"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.05,
"tooltip": "Artistic multiplier for the final calculated noise. 1.0 is physically accurate."
}),
"noise_blur": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 1.5, "step": 0.01,
"tooltip": "Applies a Gaussian blur to the final noise pattern to simulate softness."
}),
"seed": ("INT", {
"default": 0, "min": 0, "max": 0xffffffffffffffff,
"tooltip": "The random seed for the noise generation, allowing for reproducible patterns."
}),
},
}
RETURN_TYPES = (IO.IMAGE,)
OUTPUT_TOOLTIPS = ("📸 Image with scientifically accurate DSLR camera noise applied using real sensor physics.",)
FUNCTION = "add_camera_noise"
CATEGORY = "pirog/image"
DESCRIPTION = "🔬 Adds scientifically accurate DSLR camera sensor noise based on real sensor physics and measurements. Includes shot noise (Poisson), read noise (electronic), thermal noise (heat), PRNU (pixel variation), and pattern noise (banding). Each component can be controlled independently for realistic or artistic effects."
def add_camera_noise(self, image, iso, profile, temperature, exposure_time, overall_strength, noise_blur, seed):
np_seed = seed % (2**32)
torch.manual_seed(seed)
np.random.seed(np_seed)
params = CAMERA_PROFILES[profile]
img_np = image.cpu().numpy()
batch_size, height, width, channels = img_np.shape
linear_images = srgb_to_linear(img_np)
signal_e = linear_images * params["full_well_capacity"]
output_images = []
for b in range(batch_size):
current_signal_e = signal_e[b].copy()
# 1. PRNU (Photo Response Non-Uniformity) - Multiplicative signal noise
prnu_noise = np.random.normal(1.0, params["prnu_factor"], current_signal_e.shape)
current_signal_e *= prnu_noise
### MODIFIED ###
# To make exposure_time meaningful, we simulate the lighting conditions. A long exposure
# implies a dark scene (fewer photons per second). We scale the signal down for the
# purpose of noise calculation to reflect this, which correctly reduces shot noise
# and makes read noise more prominent, as it would be in a real long exposure.
reference_exposure = 1.0 / 125.0 # A typical baseline shutter speed
safe_exposure_time = max(exposure_time, 1e-6) # Avoid division by zero
signal_scaler = reference_exposure / safe_exposure_time
scaled_signal_for_noise_calc = current_signal_e * signal_scaler
# 2. Shot Noise (Pre-gain)
# We now use the scaled signal to calculate shot noise. The original signal is used
# later to preserve the image's actual brightness.
shot_noise_e = safe_poisson(np.maximum(scaled_signal_for_noise_calc, 0)) - scaled_signal_for_noise_calc
# 3. Thermal Noise (Dark Current & DSNU) (Pre-gain)
# This part correctly uses exposure_time already, so no changes are needed.
temp_factor = 2.0 ** ((temperature - 20.0) / 7.0)
dark_current_e = params["dark_current_base"] * temp_factor * exposure_time
dsnu_pattern = np.random.normal(1.0, params["dsnu_factor"], current_signal_e.shape)
dark_current_with_dsnu = dark_current_e * dsnu_pattern
thermal_noise_e = safe_poisson(np.maximum(dark_current_with_dsnu, 0)) - dark_current_with_dsnu
# 4. ISO Gain and Read Noise (Post-gain)
gain = max(iso / 100.0, 1.0)
read_noise_std = params["read_noise_base_e"] + (gain - 1.0) * params["read_noise_iso_scaling"]
base_luma_noise = np.random.normal(0, read_noise_std, (height, width, 1))
# Create low-resolution chroma noise and upsample to match image dimensions
chroma_h, chroma_w = max(1, height // 2), max(1, width // 2)
lowres_chroma_noise = np.random.normal(0, read_noise_std, (chroma_h, chroma_w, channels))
# Upsample by repeating, ensuring we get at least the target dimensions
repeat_h = int(np.ceil(height / chroma_h))
repeat_w = int(np.ceil(width / chroma_w))
chroma_noise = np.repeat(np.repeat(lowres_chroma_noise, repeat_h, axis=0), repeat_w, axis=1)
# Crop to exact dimensions
chroma_noise = chroma_noise[:height, :width, :]
chroma_factor = params["chroma_noise_factor"]
read_noise = base_luma_noise * (1.0 - chroma_factor) + chroma_noise * chroma_factor
# Note: The blur application was removed from here.
# 5. Combine All Noise Components
# The original signal is NOT amplified. Pre-gain noise is amplified. Post-gain noise is not.
total_noise = (shot_noise_e + thermal_noise_e) * gain + read_noise
### MODIFIED ###
# The blur is now applied to the FINAL combined noise map. This is more efficient
# and better simulates how the entire camera pipeline (demosaicing, etc.) can
# soften the appearance of noise.
if noise_blur > 0.0:
# Assuming apply_noise_blur takes the noise map and a sigma value for the blur.
# A direct mapping is used here, but you could scale it, e.g., blur_sigma = noise_blur * 0.8
blur_sigma = noise_blur
total_noise = apply_noise_blur(total_noise, blur_sigma)
# 6. Add Noise to Signal and Apply Overall Strength
# The base signal is the original `current_signal_e` to preserve brightness.
signal_with_all_noise = current_signal_e + (total_noise * overall_strength)
# 7. Quantize and Convert Back
conversion_gain = params["full_well_capacity"] / params["quantization_levels"]
final_adu = signal_with_all_noise / conversion_gain
clipped_adu = np.clip(final_adu, 0, params["quantization_levels"])
img_linear = clipped_adu / params["quantization_levels"]
img_final = linear_to_srgb(img_linear)
output_images.append(img_final)
result = np.stack(output_images, axis=0)
return (torch.from_numpy(result).float(),)
class TestResetButton:
"""
Test Reset Button Node
A simple test node that demonstrates custom JavaScript reset button functionality.
Features an integer input field with a default value of 5 and a reset button that
restores the value to default when clicked.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"test_value": ("INT", {
"default": 5,
"min": 0,
"max": 10,
"step": 1,
"tooltip": "Test integer value with reset button functionality"
}),
}
}
RETURN_TYPES = ("INT",)
OUTPUT_TOOLTIPS = ("The test integer value",)
FUNCTION = "get_value"
CATEGORY = "pirog/image"
DESCRIPTION = "Test node for demonstrating custom reset button functionality. Features an integer field with reset button that restores to default value (5)."
def get_value(self, test_value):
"""Returns the test value as output"""
return (test_value,)
class LensSimulatedBloom:
"""
Lens-simulated Bloom Node
Applies a high-quality, hybrid bloom effect to input images using a combination of
multi-pass downsampling blur (haze) and FFT convolution with custom kernels (spikes).
This creates realistic camera lens bloom effects with soft atmospheric glow and
sharp structured diffraction artifacts.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"threshold": ("FLOAT", {
"default": 0.8,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "Luminosity threshold to isolate highlights (0.0-1.0)"
}),
"iterations": ("INT", {
"default": 4,
"min": 1,
"max": 16,
"step": 1,
"tooltip": "Number of blur layers to generate"
}),
"amount": ("FLOAT", {
"default": 10.0,
"min": 0.1,
"max": 100.0,
"step": 0.1,
"tooltip": "Initial blur strength, which is halved for each subsequent iteration"
}),
"opacity": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "Opacity of the bloom effect. 0.0 is original image, 1.0 is full bloom."
})
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "apply_bloom"
CATEGORY = "Pirog/image"
DESCRIPTION = "Applies a bloom effect based on a Photoshop workflow using luminosity masking and layer blending."
def apply_bloom(self, image, threshold, iterations, amount, opacity):
"""Apply the bloom effect to input images"""
try:
import cv2
import numpy as np
except ImportError:
raise RuntimeError("Missing required libraries for Bloom node. Please install numpy and opencv-python.")
img_np_in = tensor_to_numpy(image)
output_images = []
for single_img_in in img_np_in:
img_uint8 = (single_img_in * 255).astype(np.uint8)
lab_image = cv2.cvtColor(img_uint8, cv2.COLOR_RGB2LAB)
l_channel = lab_image[:,:,0]
threshold_value = threshold * 255
_, mask = cv2.threshold(l_channel, threshold_value, 255, cv2.THRESH_BINARY)
mask_3d = (np.expand_dims(mask, axis=2) > 0).astype(float)
processing_canvas = single_img_in * mask_3d
blurred_layers = []
current_blur_amount = amount
for _ in range(iterations):
if current_blur_amount > 0:
# Kernel size must be an odd number, derived from sigma (amount)
ksize = int(current_blur_amount * 3)
if ksize % 2 == 0:
ksize += 1
blurred_layer = cv2.GaussianBlur(processing_canvas, (ksize, ksize), current_blur_amount)
blurred_layers.append(blurred_layer)
else:
blurred_layers.append(processing_canvas.copy())
current_blur_amount /= 2.0
bloom_result_image = single_img_in.copy()
for blurred_layer in blurred_layers:
bloom_result_image = np.maximum(bloom_result_image, blurred_layer)
# Blend with original image based on opacity
final_image = (single_img_in * (1.0 - opacity)) + (bloom_result_image * opacity)
final_image = np.clip(final_image, 0.0, 1.0)
output_images.append(final_image)
result_array = np.stack(output_images, axis=0)
result_tensor = numpy_to_tensor(result_array)
return (result_tensor,)
# ====================================
# CROP-UNCROP NODES INTEGRATION
# ====================================
class CropImage:
"""
Crops images from specified sides (left, right, top, bottom)
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"left": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}),
"right": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}),
"top": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}),
"bottom": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}),
},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "crop"
CATEGORY = "pirog/image"
def crop(self, images, left, right, top, bottom):
result_images = []
for img in images:
height, width = img.shape[:2]
# Calculate remaining dimensions after cropping
new_width = width - left - right
new_height = height - top - bottom
# Ensure dimensions don't become negative
if new_width <= 0 or new_height <= 0:
logging.warning(f"Crop values too large for image size {width}x{height}")
result_images.append(img)
continue
# Crop image sequentially
result = img
if left > 0:
result = result[:, left:]
if right > 0:
result = result[:, :-right]
if top > 0:
result = result[top:]
if bottom > 0:
result = result[:-bottom]
result_images.append(result)
return (torch.stack(result_images),)
class BatchCropFromMaskSimple:
"""
Crops images based on mask boundaries with expansion
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"masks": ("MASK",),
"expansion": ("INT", {"default": 0, "min": 0, "max": 512, "step": 1}),
"use_binary_mask": ("BOOLEAN", {"default": True}),
"threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
},
}
RETURN_TYPES = ("IMAGE", "BBOX")
RETURN_NAMES = ("cropped_images", "bboxes")
FUNCTION = "crop"
CATEGORY = "pirog/image"
def get_bbox_from_mask(self, mask_tensor, expansion, use_binary_mask, threshold):
# Apply binary thresholding if requested
if use_binary_mask:
# Convert to binary: values >= threshold become 1.0, values < threshold become 0.0
mask = (mask_tensor >= threshold).cpu().numpy()
else:
# Use original grayscale behavior (anything > 0.5 is considered active)
mask = (mask_tensor > 0.5).cpu().numpy()
y_indices, x_indices = np.nonzero(mask)
if len(x_indices) == 0 or len(y_indices) == 0:
return None
min_x = max(0, np.min(x_indices) - expansion)
max_x = min(mask.shape[1], np.max(x_indices) + expansion)
min_y = max(0, np.min(y_indices) - expansion)
max_y = min(mask.shape[0], np.max(y_indices) + expansion)
return (min_x, min_y, max_x - min_x, max_y - min_y)
def crop(self, images, masks, expansion, use_binary_mask, threshold):
bboxes = []
cropped_images = []
for img, mask in zip(images, masks):
bbox = self.get_bbox_from_mask(mask, expansion, use_binary_mask, threshold)
if bbox is None:
continue
min_x, min_y, width, height = bbox
cropped = img[min_y:min_y+height, min_x:min_x+width]
cropped_images.append(cropped)
bboxes.append(bbox)
if not cropped_images:
return (images, [(0, 0, images[0].shape[1], images[0].shape[0])])
return (torch.stack(cropped_images), bboxes)
class BatchUncropSimple:
"""
Uncrops images back to their original size with blending
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"original_images": ("IMAGE",),
"cropped_images": ("IMAGE",),
"bboxes": ("BBOX",),
"blend_width": ("INT", {"default": 16, "min": 0, "max": 256, "step": 1}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "uncrop"
CATEGORY = "pirog/image"
def create_blend_mask(self, size, blend_width):
mask = np.ones(size)
if blend_width > 0:
for i in range(blend_width):
alpha = i / blend_width
mask[:, i] *= alpha
mask[:, -(i+1)] *= alpha
mask[i, :] *= alpha
mask[-(i+1), :] *= alpha
return torch.from_numpy(mask).float()
def uncrop(self, original_images, cropped_images, bboxes, blend_width):
num_outputs = max(len(original_images), len(cropped_images))
result_images = []
device = original_images.device
for i in range(num_outputs):
# Get original image (cycle if needed)
orig_img = original_images[i % len(original_images)].clone()
# Get cropped image and bbox
crop_img = cropped_images[i % len(cropped_images)]
bbox = bboxes[i % len(bboxes)]
x, y, w, h = bbox
# Ensure bbox coordinates are within original image bounds
orig_h, orig_w = orig_img.shape[:2]
x = max(0, min(x, orig_w - 1))
y = max(0, min(y, orig_h - 1))
w = min(w, orig_w - x)
h = min(h, orig_h - y)
# Get actual crop dimensions
crop_h, crop_w = crop_img.shape[:2]
# Always resize crop to match the actual region we're going to fill
# This ensures dimensional consistency
crop_img_resized = torch.nn.functional.interpolate(
crop_img.unsqueeze(0).permute(0, 3, 1, 2), # Add batch dim and convert to BCHW
size=(h, w),
mode='bilinear',
align_corners=False
).squeeze(0).permute(1, 2, 0) # Remove batch dim and convert back to HWC
# Create blend mask with the exact same dimensions as the target region
target_region_shape = orig_img[y:y+h, x:x+w].shape[:2]
blend_mask = self.create_blend_mask(target_region_shape, blend_width).to(device)
# Ensure the mask dimensions match exactly
if blend_mask.shape != target_region_shape:
logging.warning(f"Warning: Adjusting blend mask from {blend_mask.shape} to {target_region_shape}")
blend_mask = torch.nn.functional.interpolate(
blend_mask.unsqueeze(0).unsqueeze(0),
size=target_region_shape,
mode='bilinear',
align_corners=False
).squeeze(0).squeeze(0)
for c in range(3):
orig_img[y:y+h, x:x+w, c] = (
crop_img_resized[:, :, c] * blend_mask +
orig_img[y:y+h, x:x+w, c] * (1 - blend_mask)
)
result_images.append(orig_img)
return (torch.stack(result_images),)
class CropMaskByBBox:
"""
Crops masks using bounding box coordinates
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"masks": ("MASK",),
"bboxes": ("BBOX",),
},
}
RETURN_TYPES = ("MASK",)
FUNCTION = "crop"
CATEGORY = "pirog/image"
def crop(self, masks, bboxes):
cropped_masks = []
for mask, bbox in zip(masks, bboxes):
x, y, width, height = bbox
# Crop the mask using bbox coordinates
cropped = mask[y:y+height, x:x+width]
cropped_masks.append(cropped)
# Stack the cropped masks
return (torch.stack(cropped_masks),)
# ====================================
# MASK PROCESSING NODES
# ====================================
class BlurMask:
"""
Blurs masks with smart edge handling to prevent feathering at image boundaries
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mask": ("MASK",),
"blur_radius": ("FLOAT", {"default": 2.0, "min": 0.0, "max": 100.0, "step": 0.1}),
"tapered_corners": ("BOOLEAN", {"default": True}),
},
}
RETURN_TYPES = ("MASK",)
FUNCTION = "blur_mask"
CATEGORY = "pirog/mask"
def blur_mask(self, mask, blur_radius, tapered_corners):
if blur_radius == 0:
return (mask,)
# Convert mask to numpy for processing
mask_np = mask.cpu().numpy()
batch_size = mask_np.shape[0]
result_masks = []
for i in range(batch_size):
current_mask = mask_np[i]
height, width = current_mask.shape
if tapered_corners:
# Create a distance map from image boundaries
# This identifies how far each pixel is from the nearest image edge
y_coords, x_coords = np.mgrid[:height, :width]
# Distance from each edge
dist_from_top = y_coords.astype(np.float32)
dist_from_bottom = (height - 1 - y_coords).astype(np.float32)
dist_from_left = x_coords.astype(np.float32)
dist_from_right = (width - 1 - x_coords).astype(np.float32)
# Minimum distance to any edge - using stacking and min for compatibility
edge_distance = np.minimum(
np.minimum(dist_from_top, dist_from_bottom),
np.minimum(dist_from_left, dist_from_right)
)
# Calculate blur protection zone
# Pixels within blur_radius distance from edge get protection
protection_radius = max(1, int(blur_radius * 2))
boundary_mask = edge_distance < protection_radius
if np.any(boundary_mask):
# For pixels near boundaries, use reflection padding to prevent feathering
from scipy.ndimage import gaussian_filter
blurred_reflected = gaussian_filter(current_mask, sigma=blur_radius, mode='reflect')
# For interior pixels, use normal blur
blurred_normal = gaussian_filter(current_mask, sigma=blur_radius, mode='constant')
# Create smooth transition between boundary and interior regions
# Distance-based blending with smooth falloff
normalized_distance = np.clip(edge_distance / protection_radius, 0, 1)
# Use smoothstep function for natural transition
blend_factor = normalized_distance * normalized_distance * (3 - 2 * normalized_distance)
# Blend: near boundaries use reflected blur, far from boundaries use normal blur
result = blurred_reflected * (1 - blend_factor) + blurred_normal * blend_factor
else:
# If no pixels are near boundaries, just use normal blur
from scipy.ndimage import gaussian_filter
result = gaussian_filter(current_mask, sigma=blur_radius, mode='constant')
else:
# Simple gaussian blur without edge protection
from scipy.ndimage import gaussian_filter
result = gaussian_filter(current_mask, sigma=blur_radius, mode='constant')
result_masks.append(torch.from_numpy(result))
return (torch.stack(result_masks),)
class InvertMask:
"""
Inverts mask values (0 becomes 1, 1 becomes 0)
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mask": ("MASK",),
},
}
RETURN_TYPES = ("MASK",)
FUNCTION = "invert_mask"
CATEGORY = "pirog/mask"
def invert_mask(self, mask):
return (1.0 - mask,)
class GradientMaskGenerator:
"""
Generates linear gradient masks with directional control, balance, and contrast adjustment
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"width": ("INT", {"default": 512, "min": 1, "max": 4096, "step": 1}),
"height": ("INT", {"default": 512, "min": 1, "max": 4096, "step": 1}),
"direction": (["left_to_right", "right_to_left", "top_to_bottom", "bottom_to_top"], {"default": "left_to_right"}),
"balance": ("FLOAT", {"default": 0.5, "min": 0.01, "max": 0.99, "step": 0.01}),
"contrast": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"soften": ("FLOAT", {"default": 30.0, "min": 0.0, "max": 50.0, "step": 0.1}),
},
}
RETURN_TYPES = ("MASK",)
FUNCTION = "generate_gradient"
CATEGORY = "pirog/mask"
def generate_gradient(self, width, height, direction, balance, contrast, soften):
# Create base coordinate array (0 to 1)
if direction == "left_to_right":
coords = np.linspace(0, 1, width, dtype=np.float32)
mask = np.tile(coords, (height, 1))
elif direction == "right_to_left":
coords = np.linspace(1, 0, width, dtype=np.float32)
mask = np.tile(coords, (height, 1))
elif direction == "top_to_bottom":
coords = np.linspace(0, 1, height, dtype=np.float32)
mask = np.tile(coords.reshape(-1, 1), (1, width))
elif direction == "bottom_to_top":
coords = np.linspace(1, 0, height, dtype=np.float32)
mask = np.tile(coords.reshape(-1, 1), (1, width))
# Apply balance adjustment - shift the midpoint (0.5 gray) position
if balance != 0.5:
# Create piecewise linear gradient with shifted midpoint
# balance determines where the 0.5 gray value appears in the gradient
# For each pixel position, calculate what the gradient value should be
mask_balanced = np.zeros_like(mask)
# First segment: 0 to balance position maps to 0.0 to 0.5
first_segment = mask <= balance
if np.any(first_segment):
# Linear interpolation from 0 to 0.5 over 0 to balance range
mask_balanced[first_segment] = 0.5 * (mask[first_segment] / balance)
# Second segment: balance to 1.0 position maps to 0.5 to 1.0
second_segment = mask > balance
if np.any(second_segment):
# Linear interpolation from 0.5 to 1.0 over balance to 1.0 range
mask_balanced[second_segment] = 0.5 + 0.5 * ((mask[second_segment] - balance) / (1.0 - balance))
mask = mask_balanced
# Apply contrast adjustment - expand around 0.5 midpoint toward black/white
if contrast > 0:
# Contrast pushes values away from 0.5 toward 0.0 and 1.0
# Higher contrast = more separation, sharper transitions
# Calculate distance from midpoint (0.5)
distance_from_mid = np.abs(mask - 0.5)
# Determine which side of midpoint each pixel is on
above_mid = mask >= 0.5
# Apply contrast expansion
# contrast=0: no change, contrast=1: maximum separation
expanded_distance = distance_from_mid + contrast * (0.5 - distance_from_mid)
# Reconstruct values on correct side of midpoint
mask = np.where(above_mid,
0.5 + expanded_distance, # Above midpoint: push toward 1.0
0.5 - expanded_distance) # Below midpoint: push toward 0.0
# Ensure we stay in valid range
mask = np.clip(mask, 0.0, 1.0)
# Apply directional softening (blur along gradient direction only)
if soften > 0:
mask = self.apply_directional_blur(mask, direction, soften)
# Convert to tensor and add batch dimension
mask_tensor = torch.from_numpy(mask).unsqueeze(0)
return (mask_tensor,)
def apply_directional_blur(self, mask, direction, sigma):
"""
Apply 1D Gaussian blur only along the gradient direction to soften transitions
without bleeding to perpendicular sides
"""
if sigma <= 0:
return mask
# Calculate kernel size (should be odd)
kernel_size = int(sigma * 6 + 1)
if kernel_size % 2 == 0:
kernel_size += 1
# Create 1D Gaussian kernel
kernel_1d = np.exp(-0.5 * (np.arange(kernel_size) - kernel_size // 2) ** 2 / sigma ** 2)
kernel_1d = kernel_1d / kernel_1d.sum()
# Apply directional blur based on gradient direction
if direction in ["left_to_right", "right_to_left"]:
# Horizontal gradient - blur horizontally only
# Apply 1D convolution along axis=1 (width/horizontal)
from scipy.ndimage import convolve1d
blurred = convolve1d(mask, kernel_1d, axis=1, mode='reflect')
else: # top_to_bottom or bottom_to_top
# Vertical gradient - blur vertically only
# Apply 1D convolution along axis=0 (height/vertical)
from scipy.ndimage import convolve1d
blurred = convolve1d(mask, kernel_1d, axis=0, mode='reflect')
return blurred
# ====================================
# IMAGE PROCESSING WITH MASKS
# ====================================
class ImageBlendByMask:
"""
Blends two images using a mask to control the blend regions and amount
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"source_image": ("IMAGE",),
"target_image": ("IMAGE",),
"mask": ("MASK",),
"invert_mask": ("BOOLEAN", {"default": False}),
"blend_amount": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "blend_images"
CATEGORY = "pirog/image"
def blend_images(self, source_image, target_image, mask, invert_mask, blend_amount):
# Ensure all inputs are on the same device
device = source_image.device
target_image = target_image.to(device)
mask = mask.to(device)
# Determine output batch size - use the maximum to process all images
# When counts differ, we'll cycle through the smaller batch
batch_size = max(source_image.shape[0], target_image.shape[0], mask.shape[0])
results = []
for i in range(batch_size):
# Cycle through batches if they have different sizes
source_idx = i % source_image.shape[0]
target_idx = i % target_image.shape[0]
mask_idx = i % mask.shape[0]
source_single = source_image[source_idx:source_idx+1]
target_single = target_image[target_idx:target_idx+1]
mask_single = mask[mask_idx:mask_idx+1]
# Resize images to match if they have different dimensions
if source_single.shape[1:3] != target_single.shape[1:3]:
# Resize target to match source dimensions
target_single = torch.nn.functional.interpolate(
target_single.permute(0, 3, 1, 2),
size=(source_single.shape[1], source_single.shape[2]),
mode='bilinear',
align_corners=False
).permute(0, 2, 3, 1)
# Resize mask to match image dimensions if needed
if mask_single.shape[1:3] != source_single.shape[1:3]:
mask_single = torch.nn.functional.interpolate(
mask_single.unsqueeze(1),
size=(source_single.shape[1], source_single.shape[2]),
mode='bilinear',
align_corners=False
).squeeze(1)
# Expand mask to match image channels (add channel dimension)
mask_expanded = mask_single.unsqueeze(-1).expand(-1, -1, -1, source_single.shape[-1])
# Invert mask if requested
if invert_mask:
mask_expanded = 1.0 - mask_expanded
# Apply blend amount to mask
blend_mask = mask_expanded * blend_amount
# Perform the blend: result = source * (1 - blend_mask) + target * blend_mask
result_single = source_single * (1.0 - blend_mask) + target_single * blend_mask
results.append(result_single)
# Stack all results
final_result = torch.cat(results, dim=0)
return (final_result,)
class BlurByMask:
"""
Applies selective blur to an image based on mask values
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"mask": ("MASK",),
"invert_mask": ("BOOLEAN", {"default": False}),
"blur_amount": ("FLOAT", {"default": 2.0, "min": 0.0, "max": 50.0, "step": 0.1}),
},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "blur_by_mask"
CATEGORY = "pirog/image"
def gaussian_blur_torch(self, image, sigma):
"""
Fast Gaussian blur using PyTorch operations
"""
if sigma <= 0:
return image
# Calculate kernel size (should be odd)
kernel_size = int(sigma * 6 + 1)
if kernel_size % 2 == 0:
kernel_size += 1
# Create Gaussian kernel
kernel_1d = torch.exp(-0.5 * (torch.arange(kernel_size, device=image.device, dtype=image.dtype) - kernel_size // 2) ** 2 / sigma ** 2)
kernel_1d = kernel_1d / kernel_1d.sum()
# Reshape for conv2d (out_channels, in_channels, height, width)
kernel_x = kernel_1d.view(1, 1, 1, kernel_size).expand(image.shape[-1], 1, 1, kernel_size)
kernel_y = kernel_1d.view(1, 1, kernel_size, 1).expand(image.shape[-1], 1, kernel_size, 1)
# Convert image to (batch, channels, height, width) for conv2d
img_conv = image.permute(0, 3, 1, 2)
# Apply separable Gaussian blur (horizontal then vertical)
padding = kernel_size // 2
blurred = torch.nn.functional.conv2d(img_conv, kernel_x, padding=(0, padding), groups=image.shape[-1])
blurred = torch.nn.functional.conv2d(blurred, kernel_y, padding=(padding, 0), groups=image.shape[-1])
# Convert back to (batch, height, width, channels)
return blurred.permute(0, 2, 3, 1)
def blur_by_mask(self, image, mask, invert_mask, blur_amount):
# Ensure all inputs are on the same device
device = image.device
mask = mask.to(device)
# Handle batch dimensions
batch_size = min(image.shape[0], mask.shape[0])
# Crop to matching batch size
image_batch = image[:batch_size]
mask_batch = mask[:batch_size]
# Resize mask to match image dimensions if needed
if mask_batch.shape[1:3] != image_batch.shape[1:3]:
mask_batch = torch.nn.functional.interpolate(
mask_batch.unsqueeze(1),
size=(image_batch.shape[1], image_batch.shape[2]),
mode='bilinear',
align_corners=False
).squeeze(1)
# Apply blur to the image
if blur_amount > 0:
blurred_image = self.gaussian_blur_torch(image_batch, blur_amount)
else:
blurred_image = image_batch
# Expand mask to match image channels
mask_expanded = mask_batch.unsqueeze(-1).expand(-1, -1, -1, image_batch.shape[-1])
# Invert mask if requested
if invert_mask:
mask_expanded = 1.0 - mask_expanded
# Blend: where mask is 1.0, use blurred image; where mask is 0.0, use original
result = image_batch * (1.0 - mask_expanded) + blurred_image * mask_expanded
return (result,)
class PreviewImageQueue(SaveImage):
"""
PreviewImage node with a queue button
"""
def __init__(self):
self.output_dir = folder_paths.get_temp_directory()
self.type = "temp"
self.prefix_append = "_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5))
self.compress_level = 1
@classmethod
def INPUT_TYPES(s):
return {"required":
{"images": ("IMAGE", ), },
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
CATEGORY = "pirog/image"
class LMStudioQuery(ComfyNodeABC):
"""
LM Studio Query Node
Universal node for querying LM Studio server with text and optional image inputs.
Supports model loading/unloading, multi-prompt generation with different seeds,
and batch processing of images for descriptions.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"system_prompt": ("STRING", {
"multiline": True,
"default": "You are a helpful assistant.",
"tooltip": "System prompt to set the AI's behavior and role"
}),
"user_prompt": ("STRING", {
"multiline": True,
"default": "Describe this image in detail.",
"tooltip": "User prompt for the query. Use with images for descriptions or standalone for text generation."
}),
"model_name": ("STRING", {
"default": "",
"tooltip": "Model name or partial name to search for. Leave empty to use currently loaded model."
}),
"server_url": ("STRING", {
"default": "http://localhost:1234",
"tooltip": "LM Studio server URL"
}),
"temperature": ("FLOAT", {
"default": 0.7, "min": 0.0, "max": 2.0, "step": 0.01,
"tooltip": "Sampling temperature (0.0 = deterministic, 2.0 = very random)"
}),
"max_tokens": ("INT", {
"default": 256, "min": 1, "max": 4096, "step": 1,
"tooltip": "Maximum number of tokens to generate"
}),
"top_p": ("FLOAT", {
"default": 0.9, "min": 0.0, "max": 1.0, "step": 0.01,
"tooltip": "Nucleus sampling parameter"
}),
"seed": ("INT", {
"default": 0, "min": 0, "max": 0xffffffffffffffff,
"tooltip": "Random seed for reproducible results. 0 = random each time."
}),
"prompts_number": ("INT", {
"default": 1, "min": 1, "max": 10, "step": 1,
"tooltip": "Number of prompts to generate with different seeds"
}),
"unload_after_use": ("BOOLEAN", {
"default": False,
"tooltip": "Unload the model after processing to free memory"
}),
},
"optional": {
"images": ("IMAGE", {"tooltip": "Optional images for visual queries (batch processing supported)"}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("generated_texts", "model_ref")
OUTPUT_TOOLTIPS = ("Array of generated text responses", "Reference to the loaded model name")
FUNCTION = "query_lm_studio"
CATEGORY = "pirog/ai"
DESCRIPTION = "Query LM Studio for text generation with optional image input, model management, and multi-seed prompt generation."
def query_lm_studio(self, system_prompt, user_prompt, model_name, server_url, temperature, max_tokens, top_p, seed, prompts_number, unload_after_use, images=None):
logger = logging.getLogger(__name__)
# Handle input processing - convert arrays to strings if needed
if isinstance(system_prompt, list):
system_prompt = " ".join(str(x) for x in system_prompt)
if isinstance(user_prompt, list):
user_prompt = " ".join(str(x) for x in user_prompt)
try:
# Get models list and check server in one request
models_data = self._get_models_and_check_server(server_url)
if not models_data:
raise RuntimeError(f"Cannot connect to LM Studio server at {server_url} or no models available")
models = models_data['models']
loaded_model_name = models_data.get('loaded_model')
# Determine which model to use
if model_name:
# User specified a model - find it or use loaded one
target_model = self._find_model(model_name, models)
if not target_model:
raise RuntimeError(f"Model '{model_name}' not found in available models")
# Use the target model name for API calls
api_model = target_model
else:
# No specific model - use currently loaded
if not loaded_model_name:
raise RuntimeError("No model specified and no model currently loaded")
api_model = loaded_model_name
# Prepare images if provided
image_data_list = []
if images is not None:
for i in range(images.shape[0]):
img_pil = self._tensor_to_pil(images[i])
if img_pil is None:
raise RuntimeError(f"Failed to convert image {i} to PIL")
img_base64 = self._pil_to_base64(img_pil)
image_data_list.append(img_base64)
# Generate prompts
generated_texts = []
for prompt_idx in range(prompts_number):
current_seed = seed + prompt_idx if seed != 0 else random.randint(0, 0xffffffffffffffff)
if image_data_list:
# Process each image with the current seed
for img_base64 in image_data_list:
response = self._send_vision_query(
server_url, system_prompt, user_prompt, img_base64,
temperature, max_tokens, top_p, current_seed, api_model
)
generated_texts.append(response)
else:
# Text-only query
response = self._send_text_query(
server_url, system_prompt, user_prompt,
temperature, max_tokens, top_p, current_seed, api_model
)
generated_texts.append(response)
# Unload model if requested (optional, since LM Studio may auto-manage)
if unload_after_use and api_model:
self._unload_model(server_url, api_model)
return (generated_texts, api_model)
except Exception as e:
logger.error(f"Error in LM Studio query: {str(e)}")
return ([f"Error: {str(e)}"], "")
def _get_models_and_check_server(self, server_url):
"""Get models list and check server connectivity in one fast request"""
try:
response = requests.get(f"{server_url}/v1/models", timeout=2) # Fast timeout
if response.status_code == 200:
data = response.json()
models = [model['id'] for model in data.get('data', [])]
loaded_model = None
for model in data.get('data', []):
if model.get('loaded', False):
loaded_model = model['id']
break
return {'models': models, 'loaded_model': loaded_model}
except Exception as e:
logging.warning(f"Failed to get models: {e}")
return None
def _find_model(self, model_name, available_models):
"""Find model by name (exact or partial match)"""
# Find exact match first
for model in available_models:
if model == model_name:
return model
# Find partial match
for model in available_models:
if model_name.lower() in model.lower():
return model
return None
def _load_model(self, server_url, model_name):
"""Load a model on LM Studio"""
try:
response = requests.post(
f"{server_url}/v1/models/load",
json={"model": model_name},
timeout=60 # Model loading can take time
)
if response.status_code == 200:
return model_name
except Exception as e:
logging.error(f"Failed to load model {model_name}: {e}")
return None
def _unload_model(self, server_url, model_name):
"""Unload a model from LM Studio using SDK only"""
if not LMSTUDIO_SDK_AVAILABLE:
print(f"Cannot unload model {model_name}: LM Studio SDK not available. Install with: pip install lmstudio")
return False
try:
# Extract host and port from server_url (remove http:// prefix)
parsed_url = urlparse(server_url)
api_host = f"{parsed_url.hostname}:{parsed_url.port}"
# Use SDK for proper unloading - direct unload by model key (recommended approach)
client = lmstudio.Client(api_host=api_host)
client.llm.unload(model_name)
print(f"Successfully unloaded model via SDK: {model_name}")
return True
except Exception as e:
print(f"SDK unload failed for {model_name}: {e}")
return False
def _tensor_to_pil(self, tensor):
"""Convert ComfyUI tensor to PIL Image"""
tensor = tensor.cpu().float()
if tensor.dim() == 4 and tensor.shape[0] == 1:
tensor = tensor.squeeze(0)
if tensor.dim() == 3 and tensor.shape[2] in [1, 3, 4]: # RGB/RGBA image (H, W, C)
tensor = torch.clamp(tensor, 0, 1)
array = (tensor.numpy() * 255).astype('uint8') # Already (H, W, C)
if array.shape[2] == 1:
return Image.fromarray(array.squeeze(axis=2), mode='L')
elif array.shape[2] == 3:
return Image.fromarray(array, mode='RGB')
elif array.shape[2] == 4:
return Image.fromarray(array, mode='RGBA')
elif tensor.dim() == 2: # Grayscale mask (H, W)
tensor = torch.clamp(tensor, 0, 1)
array = (tensor.numpy() * 255).astype('uint8')
return Image.fromarray(array, mode='L')
else:
raise ValueError(f"Unsupported tensor shape: {tensor.shape}, dim: {tensor.dim()}")
def _pil_to_base64(self, pil_image):
"""Convert PIL Image to base64 string"""
if pil_image is None:
raise ValueError("PIL image is None")
buffer = BytesIO()
# Try JPEG first, fallback to PNG if needed
try:
pil_image.save(buffer, format='JPEG', quality=95)
except Exception:
buffer = BytesIO()
pil_image.save(buffer, format='PNG')
return base64.b64encode(buffer.getvalue()).decode('utf-8')
def _sanitize_text_output(self, text):
"""Sanitize LLM output to escape unsafe characters for ComfyUI processing"""
if not isinstance(text, str):
return text
# Escape backslashes first (must be done before other replacements)
text = text.replace('\\', '\\\\')
# Escape parentheses that can break CLIP tokenization
text = text.replace('(', '\\(').replace(')', '\\)')
# Escape brackets that can cause parsing issues
text = text.replace('[', '\\[').replace(']', '\\]')
# Escape curly braces
text = text.replace('{', '\\{').replace('}', '\\}')
# Escape quotes that might break string processing
text = text.replace('"', '\\"').replace("'", "\\'")
# Convert newlines to spaces with separator
text = text.replace('\n', ' -- ')
# Remove control characters and non-printable characters (but keep spaces and tabs)
text = ''.join(char for char in text if ord(char) >= 32 or char in ' \t\n')
# Clean up multiple spaces
text = re.sub(r'\s+', ' ', text).strip()
return text
def _send_text_query(self, server_url, system_prompt, user_prompt, temperature, max_tokens, top_p, seed, model):
"""Send text-only query to LM Studio"""
payload = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
"temperature": temperature,
"max_tokens": max_tokens,
"top_p": top_p,
"seed": seed,
"stream": False
}
response = requests.post(f"{server_url}/v1/chat/completions", json=payload, timeout=120)
if response.status_code == 200:
data = response.json()
content = data['choices'][0]['message']['content']
return self._sanitize_text_output(content)
else:
raise RuntimeError(f"Query failed: {response.status_code} - {response.text}")
def _send_vision_query(self, server_url, system_prompt, user_prompt, image_base64, temperature, max_tokens, top_p, seed, model):
"""Send vision query with image to LM Studio"""
# Try OpenAI-compatible format first
payload = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": [
{"type": "text", "text": user_prompt},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{image_base64}"}
}
]
}
],
"temperature": temperature,
"max_tokens": max_tokens,
"top_p": top_p,
"seed": seed,
"stream": False
}
response = requests.post(f"{server_url}/v1/chat/completions", json=payload, timeout=120)
if response.status_code == 200:
data = response.json()
content = data['choices'][0]['message']['content']
return self._sanitize_text_output(content)
else:
# Try alternative format if OpenAI format fails
logging.warning(f"OpenAI format failed, trying alternative. Status: {response.status_code}")
alt_payload = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
"images": [image_base64], # Alternative format
"temperature": temperature,
"max_tokens": max_tokens,
"top_p": top_p,
"seed": seed,
"stream": False
}
response = requests.post(f"{server_url}/v1/chat/completions", json=alt_payload, timeout=120)
if response.status_code == 200:
data = response.json()
content = data['choices'][0]['message']['content']
return self._sanitize_text_output(content)
else:
raise RuntimeError(f"Vision query failed: {response.status_code} - {response.text}")
class CLIPTextEncodeMultiple(ComfyNodeABC):
"""
CLIP Text Encode Multiple Node
Encodes multiple text prompts using a CLIP model into conditioning embeddings.
Takes a list of strings and returns a list of CONDITIONING for batch processing.
Useful for tile-based workflows where each tile needs its own prompt.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"clip": ("CLIP", {"tooltip": "The CLIP model used for encoding the text."}),
"texts": ("STRING", {"tooltip": "List of text prompts to encode. Each text will be encoded separately."}),
}
}
RETURN_TYPES = ("CONDITIONING",)
OUTPUT_TOOLTIPS = ("List of conditioning embeddings, one for each input text.",)
FUNCTION = "encode_multiple"
CATEGORY = "conditioning"
DESCRIPTION = "Encodes multiple text prompts into conditioning embeddings for batch processing workflows."
def encode_multiple(self, clip, texts):
if clip is None:
raise RuntimeError("ERROR: clip input is invalid: None\n\nIf the clip is from a checkpoint loader node your checkpoint does not contain a valid clip or text encoder model.")
# Ensure texts is a list
if isinstance(texts, str):
# Try to parse string as list (e.g., "['text1', 'text2']")
try:
import ast
parsed = ast.literal_eval(texts)
if isinstance(parsed, list):
texts = parsed
else:
texts = [texts]
except (ValueError, SyntaxError):
texts = [texts]
elif not isinstance(texts, list):
texts = [str(texts)]
conditionings = []
for text in texts:
if isinstance(text, str) and text.strip():
tokens = clip.tokenize(text)
conditioning = clip.encode_from_tokens_scheduled(tokens)
conditionings.append(conditioning)
# Return as a list of conditionings for tile processing
return (conditionings,)
class CLIPTextEncodeFluxMultiple(ComfyNodeABC):
"""
CLIP Text Encode Flux Multiple Node
Encodes multiple text prompt pairs using a CLIP model into conditioning embeddings for Flux models.
Takes lists of strings for clip_l and t5xxl and returns a list of CONDITIONING for batch processing.
Useful for tile-based workflows where each tile needs its own prompt pair.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"clip": ("CLIP", {"tooltip": "The CLIP model used for encoding the text."}),
"clip_l_texts": ("STRING", {"tooltip": "List of CLIP-L text prompts to encode. Each text will be encoded separately."}),
"t5xxl_texts": ("STRING", {"tooltip": "List of T5-XXL text prompts to encode. Each text will be paired with corresponding clip_l_texts."}),
"guidance": ("FLOAT", {"default": 3.5, "min": 0.0, "max": 100.0, "step": 0.1, "tooltip": "Guidance scale for Flux models."}),
}
}
RETURN_TYPES = ("CONDITIONING",)
OUTPUT_TOOLTIPS = ("List of conditioning embeddings, one for each input text pair.",)
FUNCTION = "encode_flux_multiple"
CATEGORY = "advanced/conditioning/flux"
DESCRIPTION = "Encodes multiple text prompt pairs into conditioning embeddings for Flux models in batch processing workflows."
def encode_flux_multiple(self, clip, clip_l_texts, t5xxl_texts, guidance):
if clip is None:
raise RuntimeError("ERROR: clip input is invalid: None\n\nIf the clip is from a checkpoint loader node your checkpoint does not contain a valid clip or text encoder model.")
# Ensure inputs are lists
def parse_input(input_val):
if isinstance(input_val, str):
# Try to parse string as list (e.g., "['text1', 'text2']")
try:
import ast
parsed = ast.literal_eval(input_val)
if isinstance(parsed, list):
return parsed
else:
return [input_val]
except (ValueError, SyntaxError):
return [input_val]
elif not isinstance(input_val, list):
return [str(input_val)]
return input_val
clip_l_texts = parse_input(clip_l_texts)
t5xxl_texts = parse_input(t5xxl_texts)
# Ensure both lists have the same length
max_len = max(len(clip_l_texts), len(t5xxl_texts))
if len(clip_l_texts) < max_len:
clip_l_texts.extend([clip_l_texts[-1]] * (max_len - len(clip_l_texts)))
if len(t5xxl_texts) < max_len:
t5xxl_texts.extend([t5xxl_texts[-1]] * (max_len - len(t5xxl_texts)))
conditionings = []
for clip_l_text, t5xxl_text in zip(clip_l_texts, t5xxl_texts):
if isinstance(clip_l_text, str) and isinstance(t5xxl_text, str) and (clip_l_text.strip() or t5xxl_text.strip()):
tokens = clip.tokenize(clip_l_text)
tokens["t5xxl"] = clip.tokenize(t5xxl_text)["t5xxl"]
conditioning = clip.encode_from_tokens_scheduled(tokens, add_dict={"guidance": guidance})
conditionings.append(conditioning)
# Return as a list of conditionings for tile processing
return (conditionings,)
class BatchLoadImages(ComfyNodeABC):
"""
Batch Load Images Node
Loads all images from specified directory.
Supports JPG, PNG, WEBP formats.
Returns image batch and corresponding filenames.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"directory_path": ("STRING", {
"default": "./input_images",
"tooltip": "Path to directory containing images to load"
}),
"recursive": ("BOOLEAN", {
"default": False,
"tooltip": "Search recursively in subdirectories"
}),
}
}
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("images", "filenames")
OUTPUT_TOOLTIPS = ("Batch of loaded images", "List of corresponding filenames")
FUNCTION = "load_images"
CATEGORY = "pirog/image"
DESCRIPTION = "Loads all JPG, PNG, WEBP images from specified directory. Returns image batch and filename list for batch processing."
def load_images(self, directory_path, recursive):
"""Load all supported images from directory"""
import os
from PIL import Image
supported_formats = ('.jpg', '.jpeg', '.png', '.webp')
images = []
filenames = []
try:
# Normalize path
directory_path = os.path.expanduser(directory_path)
if recursive:
# Recursive search
for root, dirs, files in os.walk(directory_path):
for file in files:
if file.lower().endswith(supported_formats):
full_path = os.path.join(root, file)
try:
# Load image
pil_image = Image.open(full_path)
# Convert to RGB if needed
if pil_image.mode != 'RGB':
pil_image = pil_image.convert('RGB')
# Convert to tensor
img_tensor = pil_to_tensor(pil_image)
images.append(img_tensor)
filenames.append(file)
except Exception as e:
print(f"Error loading {full_path}: {e}")
continue
else:
# Non-recursive search
if os.path.exists(directory_path):
for file in os.listdir(directory_path):
if file.lower().endswith(supported_formats):
full_path = os.path.join(directory_path, file)
try:
# Load image
pil_image = Image.open(full_path)
# Convert to RGB if needed
if pil_image.mode != 'RGB':
pil_image = pil_image.convert('RGB')
# Convert to tensor
img_tensor = pil_to_tensor(pil_image)
images.append(img_tensor)
filenames.append(file)
except Exception as e:
print(f"Error loading {full_path}: {e}")
continue
if not images:
raise RuntimeError(f"No supported images found in directory: {directory_path}")
# Stack images into batch
image_batch = torch.stack(images)
return (image_batch, filenames)
except Exception as e:
raise RuntimeError(f"Error loading images from {directory_path}: {e}")
class BatchSaveImages(ComfyNodeABC):
"""
Batch Save Images Node
Saves batch of images with optional auto-renaming.
Supports multiple formats and custom output directory.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE", {
"tooltip": "Batch of images to save"
}),
"filenames": ("STRING", {
"tooltip": "List of filenames (used when auto_rename is False)"
}),
"output_directory": ("STRING", {
"default": "./output_images",
"tooltip": "Directory to save images to"
}),
"auto_rename": ("BOOLEAN", {
"default": True,
"tooltip": "Auto-generate filenames in format 0001_filename.jpg (ignores input filenames)"
}),
"base_filename": ("STRING", {
"default": "image",
"tooltip": "Base name for auto-generated filenames (only used when auto_rename is True)"
}),
"format": (["jpg", "png", "webp"], {
"default": "jpg",
"tooltip": "Output image format"
}),
"quality": ("INT", {
"default": 95,
"min": 1,
"max": 100,
"step": 1,
"tooltip": "Image quality (for JPG/WEBP formats)"
}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("saved_paths")
OUTPUT_TOOLTIPS = ("List of paths where images were saved",)
FUNCTION = "save_images"
CATEGORY = "pirog/image"
DESCRIPTION = "Saves batch of images with auto-renaming option. Generates filenames like 0001_basename.jpg or uses provided filenames. Supports JPG, PNG, WEBP formats."
def save_images(self, images, filenames, output_directory, auto_rename, base_filename, format, quality):
"""Save batch of images to disk"""
import os
from PIL import Image
saved_paths = []
try:
# Create output directory if it doesn't exist
os.makedirs(output_directory, exist_ok=True)
# Ensure filenames is a list
if isinstance(filenames, str):
# If single string, create list with one element
filenames = [filenames] * images.shape[0]
elif not isinstance(filenames, list):
filenames = [str(filenames)] * images.shape[0]
# Ensure we have enough filenames
while len(filenames) < images.shape[0]:
filenames.append(f"image_{len(filenames)}")
batch_size = images.shape[0]
for i in range(batch_size):
try:
# Get image tensor
img_tensor = images[i]
# Convert to PIL
pil_image = tensor_to_pil(img_tensor)
# Generate filename
if auto_rename:
# Format: 0001_basename.jpg
filename = "04d"
if base_filename:
filename = f"{filename}_{base_filename}"
filename = f"{filename}.{format}"
else:
# Use provided filename but change extension
original_name = filenames[i] if i < len(filenames) else f"image_{i}"
# Remove extension and add new one
name_without_ext = os.path.splitext(original_name)[0]
filename = f"{name_without_ext}.{format}"
# Full path
full_path = os.path.join(output_directory, filename)
# Save with appropriate format settings
if format.lower() == "jpg":
pil_image.save(full_path, "JPEG", quality=quality)
elif format.lower() == "png":
pil_image.save(full_path, "PNG")
elif format.lower() == "webp":
pil_image.save(full_path, "WEBP", quality=quality)
saved_paths.append(full_path)
print(f"Saved image to: {full_path}")
except Exception as e:
print(f"Error saving image {i}: {e}")
continue
return (saved_paths,)
except Exception as e:
raise RuntimeError(f"Error saving images: {e}")
class BlendImages(ComfyNodeABC):
"""
Blend Images Node
Blends two images using various blend modes supported by PIL.
Provides full control over blend amount and supports all major blend modes.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image1": ("IMAGE", {"tooltip": "First image to blend"}),
"image2": ("IMAGE", {"tooltip": "Second image to blend"}),
"blend_amount": ("FLOAT", {
"default": 0.5,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "Opacity control: 0.0 = original image, 1.0 = full blend effect. For normal mode: blends between image1/image2 directly."
}),
"blend_mode": ([
"normal", "add", "subtract", "multiply", "screen",
"overlay", "soft_light", "hard_light", "color_dodge",
"color_burn", "lighten", "darken", "difference"
], {"tooltip": "Blend mode to use for combining the images"}),
}
}
RETURN_TYPES = ("IMAGE",)
OUTPUT_TOOLTIPS = ("Blended image result",)
FUNCTION = "blend_images"
CATEGORY = "pirog/image"
DESCRIPTION = "Blends two images using various PIL-supported blend modes. Blend amount controls opacity: 0.0 = original image, 1.0 = full blend effect. Normal mode blends directly between input images."
def blend_images(self, image1, image2, blend_amount, blend_mode):
"""Blend two images using the specified blend mode and amount"""
try:
# Ensure tensors are on the same device
device = image1.device
image2 = image2.to(device)
# Handle batch processing - use the maximum batch size
batch_size = max(image1.shape[0], image2.shape[0])
results = []
for i in range(batch_size):
# Cycle through batches if sizes differ
img1_idx = i % image1.shape[0]
img2_idx = i % image2.shape[0]
# Convert tensors to PIL images
pil_img1 = tensor_to_pil(image1[img1_idx])
pil_img2 = tensor_to_pil(image2[img2_idx])
# Ensure images are the same size
if pil_img1.size != pil_img2.size:
# Resize image2 to match image1
pil_img2 = pil_img2.resize(pil_img1.size, Image.LANCZOS)
# Ensure both images are in RGB mode
if pil_img1.mode != 'RGB':
pil_img1 = pil_img1.convert('RGB')
if pil_img2.mode != 'RGB':
pil_img2 = pil_img2.convert('RGB')
# Apply the blend mode
if blend_mode == "normal":
# Simple alpha blending between image1 and image2
blended = Image.blend(pil_img1, pil_img2, blend_amount)
else:
# For non-normal modes: compute full blend result C, then blend between A and C
full_blend_result = self._apply_blend_mode(pil_img1, pil_img2, blend_mode)
# blend_amount controls opacity between original image1 (A) and full blend result (C)
blended = Image.blend(pil_img1, full_blend_result, blend_amount)
# Convert back to tensor
result_tensor = pil_to_tensor(blended)
results.append(result_tensor)
# Concatenate results along the batch dimension
final_result = torch.cat(results, dim=0)
return (final_result,)
except Exception as e:
logger.error(f"Error in blend_images: {str(e)}")
# Return first image as fallback
return (image1,)
def _apply_blend_mode(self, img1, img2, mode):
"""Apply specific blend mode using ImageChops"""
if mode == "add":
return ImageChops.add(img1, img2, scale=1.0, offset=0)
elif mode == "subtract":
return ImageChops.subtract(img1, img2, scale=1.0, offset=0)
elif mode == "multiply":
return ImageChops.multiply(img1, img2)
elif mode == "screen":
# Screen blend: 1 - (1-a)(1-b)
return ImageChops.screen(img1, img2)
elif mode == "overlay":
return ImageChops.overlay(img1, img2)
elif mode == "soft_light":
return ImageChops.soft_light(img1, img2)
elif mode == "hard_light":
return ImageChops.hard_light(img1, img2)
elif mode == "color_dodge":
return ImageChops.add(img1, img2, scale=1.0, offset=0) # Approximation
elif mode == "color_burn":
return ImageChops.subtract(img1, img2, scale=1.0, offset=0) # Approximation
elif mode == "lighten":
return ImageChops.lighter(img1, img2)
elif mode == "darken":
return ImageChops.darker(img1, img2)
elif mode == "difference":
return ImageChops.difference(img1, img2)
else:
# Fallback to normal blend
return Image.blend(img1, img2, 0.5)
class IfNode(ComfyNodeABC):
"""
IF Node
Conditional node that returns one of two inputs based on boolean condition.
Supports any data type for true/false inputs.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"condition": ("BOOLEAN", {
"tooltip": "Boolean condition - True returns input_true, False returns input_false"
}),
"input_true": ("*", {
"tooltip": "Value to return when condition is True"
}),
"input_false": ("*", {
"tooltip": "Value to return when condition is False"
}),
}
}
RETURN_TYPES = ("*",)
OUTPUT_TOOLTIPS = ("The selected input based on condition",)
FUNCTION = "execute"
CATEGORY = "pirog/logic"
DESCRIPTION = "Conditional node that selects between two inputs based on boolean condition. Returns input_true if condition is True, input_false if condition is False."
def execute(self, condition, input_true, input_false):
"""Execute conditional logic"""
if condition:
return (input_true,)
else:
return (input_false,)
class LMStudioUnloadModel(ComfyNodeABC):
"""
LM Studio Unload Model Node
Companion node to unload models from LM Studio memory.
Takes model reference and unloads the model, with passthrough for text and images.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model_ref": ("STRING", {
"tooltip": "Reference to the model name to unload from LM Studio"
}),
"server_url": ("STRING", {
"default": "http://localhost:1234",
"tooltip": "LM Studio server URL"
}),
},
"optional": {
"system_prompt": ("STRING", {"tooltip": "Passthrough for system prompt"}),
"user_prompt": ("STRING", {"tooltip": "Passthrough for user prompt"}),
"images": ("IMAGE", {"tooltip": "Passthrough for images"}),
"generated_texts": ("STRING", {"tooltip": "Passthrough for generated texts"}),
}
}
RETURN_TYPES = ("STRING", "STRING", "IMAGE", "STRING")
RETURN_NAMES = ("system_prompt", "user_prompt", "images", "generated_texts")
OUTPUT_TOOLTIPS = ("Passthrough system prompt", "Passthrough user prompt", "Passthrough images", "Passthrough generated texts")
FUNCTION = "unload_model"
CATEGORY = "pirog/ai"
DESCRIPTION = "Unload a model from LM Studio memory with passthrough for text and images to maintain chain connectivity."
def unload_model(self, model_ref, server_url, system_prompt="", user_prompt="", images=None, generated_texts=""):
try:
if not model_ref:
logger.warning("No model reference provided for unloading")
return (system_prompt, user_prompt, images, generated_texts)
# Try to unload the model using SDK
success = self._unload_model(server_url, model_ref)
if success:
logger.info(f"Successfully unloaded model: {model_ref}")
else:
logger.warning(f"Failed to unload model: {model_ref} (SDK not available or model not found)")
# Return passthrough values
return (system_prompt, user_prompt, images, generated_texts)
except Exception as e:
logger.error(f"Error unloading model: {str(e)}")
return (system_prompt, user_prompt, images, generated_texts)
def _check_server(self, server_url):
"""Check if LM Studio server is running"""
try:
response = requests.get(f"{server_url}/v1/models", timeout=2)
return response.status_code == 200
except:
return False
def _unload_model(self, server_url, model_name):
"""Unload a model from LM Studio using SDK only"""
if not LMSTUDIO_SDK_AVAILABLE:
logger.error(f"Cannot unload model {model_name}: LM Studio SDK not available. Install with: pip install lmstudio")
return False
try:
# Extract host and port from server_url (remove http:// prefix)
parsed_url = urlparse(server_url)
api_host = f"{parsed_url.hostname}:{parsed_url.port}"
# Use SDK for proper unloading - direct unload by model key (recommended approach)
client = lmstudio.Client(api_host=api_host)
client.llm.unload(model_name)
logger.info(f"Successfully unloaded model via SDK: {model_name}")
return True
except Exception as e:
logger.error(f"SDK unload failed for {model_name}: {e}")
return False