From b5b2189c1aa73ebf708669c94bf0be05df4dbeee Mon Sep 17 00:00:00 2001 From: Slava Cherkasov Date: Tue, 4 Nov 2025 16:16:59 +0100 Subject: [PATCH] preview video scaled, load gif, fixes --- js/SmartImagePreviewScaled.js | 44 +++ nodes.py | 680 +++++++++++++++++++++++++++++++++- 2 files changed, 706 insertions(+), 18 deletions(-) create mode 100644 js/SmartImagePreviewScaled.js diff --git a/js/SmartImagePreviewScaled.js b/js/SmartImagePreviewScaled.js new file mode 100644 index 0000000..a34fa8f --- /dev/null +++ b/js/SmartImagePreviewScaled.js @@ -0,0 +1,44 @@ +import { app } from "../../../scripts/app.js"; + +app.registerExtension({ + name: "Comfy.SmartNodes.SmartImagePreviewScaled", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "SmartImagePreviewScaled") { + // Store original dimensions + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function(message) { + onExecuted?.apply(this, arguments); + + // Store original dimensions if provided + if (message?.original_dimensions && message.original_dimensions.length > 0) { + this.originalDimensions = message.original_dimensions[0]; + } else { + this.originalDimensions = null; + } + }; + + // Override the onDrawForeground to display original dimensions + const onDrawForeground = nodeType.prototype.onDrawForeground; + nodeType.prototype.onDrawForeground = function(ctx) { + const result = onDrawForeground?.apply(this, arguments); + + if (this.flags.collapsed) return result; + + // Draw original dimensions at the bottom right + if (this.originalDimensions) { + const text = `${this.originalDimensions[0]}x${this.originalDimensions[1]}`; + const bottomY = this.size[1] - 5; + + ctx.fillStyle = "#CCC"; + ctx.font = "10px Arial"; + ctx.textAlign = "right"; + ctx.textBaseline = "alphabetic"; + ctx.fillText(text, this.size[0] - 5, bottomY); + } + + return result; + }; + } + }, +}); + diff --git a/nodes.py b/nodes.py index 710574c..32e0ab8 100644 --- a/nodes.py +++ b/nodes.py @@ -1,5 +1,5 @@ import numpy as np -from PIL import Image +from PIL import Image, ImageSequence, ImageOps from PIL.PngImagePlugin import PngInfo # Added import from sklearn.cluster import KMeans from skimage import color @@ -19,6 +19,8 @@ import colorsys import json import math # Added for ceil/floor import struct +import hashlib +import node_helpers # Numba optimized functions @numba.jit(nopython=True) @@ -167,6 +169,7 @@ class SmartImagePaletteConvert: }, "optional": { "reference_image": ("IMAGE",), + "additional_colors": ("IMAGE",), } } @@ -271,7 +274,7 @@ class SmartImagePaletteConvert: palette[:len(unique_colors_float), :3] = unique_colors_float palette[:len(unique_colors_float), 3] = 255.0 # Full opacity # Use large negative RGB for transparent color temporarily (this is needed to cancel the transparent color from picking it while searching for the closest color) - palette[-1] = [-99999.0, -99999.0, -99999.0, 0.0] + palette[-1] = [-500.0, -500.0, -500.0, 0.0] else: # No alpha channel, just get unique RGB colors unique_colors = np.unique(img_flat, axis=0) @@ -283,6 +286,71 @@ class SmartImagePaletteConvert: # Return as uint8 after sorting and potential modification return np.clip(sorted_palette_float, 0, 255).astype(np.uint8) + def merge_palettes(self, palette1, palette2): + """Merge two palettes while avoiding duplicates.""" + # Ensure both palettes are float32 for processing + palette1_float = palette1.astype(np.float32) + palette2_float = palette2.astype(np.float32) + + # Determine if we need to handle alpha channels + has_alpha1 = palette1_float.shape[1] == 4 + has_alpha2 = palette2_float.shape[1] == 4 + + if has_alpha1 and has_alpha2: + # Both have alpha - merge properly + # Combine all colors + combined = np.vstack([palette1_float, palette2_float]) + + # Find unique colors based on RGB values (ignore alpha for uniqueness) + rgb_colors = combined[:, :3] + + # Create a set to track unique RGB combinations + unique_indices = [] + seen_rgb = set() + + for i, rgb in enumerate(rgb_colors): + rgb_tuple = tuple(rgb) + if rgb_tuple not in seen_rgb: + seen_rgb.add(rgb_tuple) + unique_indices.append(i) + + # Extract unique colors + unique_palette = combined[unique_indices] + + elif has_alpha1 or has_alpha2: + # One has alpha, the other doesn't - convert both to RGBA + if not has_alpha1: + # Add alpha channel to palette1 + alpha_col = np.full((palette1_float.shape[0], 1), 255.0, dtype=np.float32) + palette1_float = np.hstack([palette1_float, alpha_col]) + + if not has_alpha2: + # Add alpha channel to palette2 + alpha_col = np.full((palette2_float.shape[0], 1), 255.0, dtype=np.float32) + palette2_float = np.hstack([palette2_float, alpha_col]) + + # Now both have alpha, use the same logic as above + combined = np.vstack([palette1_float, palette2_float]) + rgb_colors = combined[:, :3] + + unique_indices = [] + seen_rgb = set() + + for i, rgb in enumerate(rgb_colors): + rgb_tuple = tuple(rgb) + if rgb_tuple not in seen_rgb: + seen_rgb.add(rgb_tuple) + unique_indices.append(i) + + unique_palette = combined[unique_indices] + + else: + # Neither has alpha - simple RGB merging + combined = np.vstack([palette1_float, palette2_float]) + unique_palette = np.unique(combined, axis=0) + + return unique_palette + def generate_palette(self, img, num_colors, has_transparency=True): """Generate an optimal color palette using K-means clustering in CIELAB space.""" # Downsample large images for faster processing @@ -473,7 +541,7 @@ class SmartImagePaletteConvert: # Palette is expected to be float32 here return self.apply_floyd_steinberg_dithering(image, palette, dithering_amount) - def convert_image(self, image, num_colors, dithering_amount, reference_image=None): + def convert_image(self, image, num_colors, dithering_amount, reference_image=None, additional_colors=None): # Convert from tensor to numpy array input_image = 255. * image.cpu().numpy() @@ -491,12 +559,29 @@ class SmartImagePaletteConvert: transparent_indices = np.where(alpha_channel == 0.0)[0] if len(transparent_indices) > 0: palette_float[transparent_indices, :3] = -99999.0 - else: # Generate optimal palette from the input image using num_colors # Returns float32 palette with modified transparent color already palette_float = self.generate_palette(input_image[0], num_colors) + # Process additional colors if provided + if additional_colors is not None: + additional_np = 255. * additional_colors.cpu().numpy() + # Extract exact palette from additional colors image + additional_palette_uint8 = self.extract_palette_from_reference(additional_np[0]) + # Convert to float32 for processing + additional_palette_float = additional_palette_uint8.astype(np.float32) + + # Ensure transparent color has modified RGB if present in additional palette + if additional_palette_float.shape[1] == 4: + alpha_channel = additional_palette_float[:, 3] + transparent_indices = np.where(alpha_channel == 0.0)[0] + if len(transparent_indices) > 0: + additional_palette_float[transparent_indices, :3] = -99999.0 + + # Merge the palettes while avoiding duplicates + palette_float = self.merge_palettes(palette_float, additional_palette_float) + # Create output array with same batch size as input batch_size = input_image.shape[0] @@ -645,7 +730,7 @@ class SmartGenerateImage: return { "required": { "color": (["red", "green", "blue", "black", "white", "yellow", "cyan", "magenta", - "gray", "orange", "purple", "brown", "pink", "transparent"],), + "gray", "dark gray", "orange", "purple", "brown", "pink", "transparent"],), "alpha": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), "use_alpha": ("BOOLEAN", {"default": True}), }, @@ -672,6 +757,7 @@ class SmartGenerateImage: "cyan": [0, 255, 255], "magenta": [255, 0, 255], "gray": [128, 128, 128], + "dark gray": [64, 64, 64], "orange": [255, 165, 0], "purple": [128, 0, 128], "brown": [165, 42, 42], @@ -982,6 +1068,7 @@ class SmartImagePreviewScaled(PreviewImage): "images": ("IMAGE",), # Change to FLOAT widget "scale_by": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 100.0, "step": 0.1}), + "transparency": (["original", "black", "white", "orange"], {"default": "original"}), }, "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, } @@ -992,10 +1079,18 @@ class SmartImagePreviewScaled(PreviewImage): CATEGORY = "SmartImageTools" # Use execute to potentially modify images before calling the parent's save logic - def execute(self, images, scale_by=1.0, filename_prefix="SmartScaledPreview", prompt=None, extra_pnginfo=None): + def execute(self, images, scale_by=1.0, transparency="original", filename_prefix="SmartScaledPreview", prompt=None, extra_pnginfo=None): + # Store original dimensions before any scaling + original_height = images.shape[1] + original_width = images.shape[2] + if abs(scale_by - 1.0) < 1e-6 or scale_by <= 0: # No scaling or invalid scale factor, use original save_images - return self.save_images(images, filename_prefix, prompt, extra_pnginfo) + result = self.save_images(images, filename_prefix, prompt, extra_pnginfo) + # Add original dimensions to the result + if "ui" in result: + result["ui"]["original_dimensions"] = [[original_width, original_height]] + return result # Perform scaling scaled_images_list = [] @@ -1021,6 +1116,29 @@ class SmartImagePreviewScaled(PreviewImage): # Resize using nearest neighbor resized_img = cv2.resize(img_np_uint8, (new_w, new_h), interpolation=cv2.INTER_NEAREST) + # --- Transparency Blending --- + if transparency != "original": + # Convert to PIL for alpha compositing + resized_pil = Image.fromarray(resized_img) + + # Ensure image is RGBA for compositing + if resized_pil.mode != 'RGBA': + resized_pil = resized_pil.convert('RGBA') + + # Get background color from BG_COLOR_MAP + if transparency in BG_COLOR_MAP and BG_COLOR_MAP[transparency] is not None: + blend_rgb = BG_COLOR_MAP[transparency] + # Create a background image of the solid color + background = Image.new('RGBA', resized_pil.size, blend_rgb + (255,)) + + # Composite the original image over the background + # This automatically handles the alpha blending + resized_pil = Image.alpha_composite(background, resized_pil) + + # Convert back to numpy array + resized_img = np.array(resized_pil) + # --- End Transparency Blending --- + # Handle potential shape issues after resize (e.g., grayscale losing channel dim) if len(resized_img.shape) == 2 and len(img_np_uint8.shape) == 3: resized_img = np.expand_dims(resized_img, axis=-1) # Add channel dim back @@ -1049,7 +1167,11 @@ class SmartImagePreviewScaled(PreviewImage): scaled_images_batch = torch.stack(scaled_images_list) # Call the parent's save_images method with the scaled images - return self.save_images(scaled_images_batch, filename_prefix, prompt, extra_pnginfo) + result = self.save_images(scaled_images_batch, filename_prefix, prompt, extra_pnginfo) + # Add original dimensions to the result + if "ui" in result: + result["ui"]["original_dimensions"] = [[original_width, original_height]] + return result class SmartPreviewPalette: @@ -1395,6 +1517,7 @@ class SmartSemiTransparenceRemove: "cyan": [0.0, 1.0, 1.0], "magenta": [1.0, 0.0, 1.0], "gray": [0.5, 0.5, 0.5], + "dark gray": [0.25, 0.25, 0.25], "orange": [1.0, 0.647, 0.0], "purple": [0.5, 0.0, 0.5], "brown": [0.647, 0.165, 0.165], @@ -1518,6 +1641,7 @@ BG_COLOR_MAP = { "cyan": (0, 255, 255), "magenta": (255, 0, 255), "gray": (128, 128, 128), + "dark gray": (64, 64, 64), "orange": (255, 165, 0), "purple": (128, 0, 128), "brown": (165, 42, 42), @@ -1545,32 +1669,44 @@ class SmartVideoPreviewScaled: def preview_video(self, images, fps, scale_by=2.0, bg_color="transparent", prompt=None, extra_pnginfo=None): # Convert tensors to PIL images and prepare for web UI image_data = [] + original_dimensions = [] + scaled_dimensions = [] + for image_tensor in images: img_np = image_tensor.cpu().numpy() # Handle potential non-standard ranges by clipping before conversion img_np = np.clip(img_np, 0.0, 1.0) img_pil = Image.fromarray((img_np * 255.0).astype(np.uint8)) + # Store original dimensions + orig_w, orig_h = img_pil.size + original_dimensions.append([orig_w, orig_h]) + # --- Scaling happens here --- if abs(scale_by - 1.0) > 1e-6 and scale_by > 0: - w, h = img_pil.size - new_w = max(1, int(round(w * scale_by))) - new_h = max(1, int(round(h * scale_by))) + new_w = max(1, int(round(orig_w * scale_by))) + new_h = max(1, int(round(orig_h * scale_by))) # Resize using NEAREST neighbor img_pil = img_pil.resize((new_w, new_h), Image.NEAREST) + else: + # If no scaling, scaled dimensions are same as original + new_w, new_h = orig_w, orig_h # --- End Scaling --- - # --- Background Blending --- + # Store scaled dimensions + scaled_dimensions.append([new_w, new_h]) + + # --- Background Blending --- if bg_color != "transparent" and bg_color in BG_COLOR_MAP: blend_rgb = BG_COLOR_MAP[bg_color] if blend_rgb is not None: # Ensure image is RGBA for compositing if img_pil.mode != 'RGBA': img_pil = img_pil.convert('RGBA') - + # Create a background image of the solid color background = Image.new('RGBA', img_pil.size, blend_rgb + (255,)) - + # Composite the original image over the background # This automatically handles the alpha blending img_pil = Image.alpha_composite(background, img_pil) @@ -1587,9 +1723,14 @@ class SmartVideoPreviewScaled: "type": "temp" }) - # Only send images and fps + # Return images, fps, and both original and scaled dimensions # Use a unique key 'video_frames' to avoid conflicts with default preview handlers - return {"ui": {"video_frames": image_data, "fps": [fps]}} + return {"ui": { + "video_frames": image_data, + "fps": [fps], + "original_dimensions": original_dimensions, + "scaled_dimensions": scaled_dimensions + }} class SmartSaveAnimatedPNG: @@ -2034,6 +2175,497 @@ class SmartDrawPoints: return (output_tensor,) +class SmartLoadGIFImage: + @classmethod + def INPUT_TYPES(s): + input_dir = folder_paths.get_input_directory() + files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] + files = folder_paths.filter_files_content_types(files, ["image", "animated"]) + return {"required": + {"image": (sorted(files), {"image_upload": True}), + "custom_frame": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1})}, + } + + CATEGORY = "SmartImageTools" + RETURN_TYPES = ("IMAGE", "MASK", "IMAGE", "MASK", "IMAGE", "MASK", "IMAGE", "MASK") + RETURN_NAMES = ("all_images", "all_masks", "first_frame", "first_mask", "last_frame", "last_mask", "custom_frame", "custom_mask") + FUNCTION = "load_gif_image" + + def load_gif_image(self, image, custom_frame): + image_path = folder_paths.get_annotated_filepath(image) + + img = node_helpers.pillow(Image.open, image_path) + + output_images = [] + output_masks = [] + w, h = None, None + + excluded_formats = ['MPO'] + + for i in ImageSequence.Iterator(img): + i = node_helpers.pillow(ImageOps.exif_transpose, i) + + if i.mode == 'I': + i = i.point(lambda i: i * (1 / 255)) + + if len(output_images) == 0: + w = i.size[0] + h = i.size[1] + + if i.size[0] != w or i.size[1] != h: + continue + + # Handle transparency properly - output RGBA when transparency exists + if 'A' in i.getbands(): + # Image has alpha channel - use RGBA directly + rgba_image = i.convert("RGBA") + alpha = np.array(rgba_image.getchannel('A')).astype(np.float32) / 255.0 + mask = 1. - torch.from_numpy(alpha) + # Output RGBA image to preserve transparency + image = np.array(rgba_image).astype(np.float32) / 255.0 + image = torch.from_numpy(image)[None,] + elif i.mode == 'P' and 'transparency' in i.info: + # Palette image with transparency - use RGBA + rgba_image = i.convert('RGBA') + alpha = np.array(rgba_image.getchannel('A')).astype(np.float32) / 255.0 + mask = 1. - torch.from_numpy(alpha) + # Output RGBA image to preserve transparency + image = np.array(rgba_image).astype(np.float32) / 255.0 + image = torch.from_numpy(image)[None,] + else: + # No transparency - output RGB with zero mask + mask = torch.zeros((h, w), dtype=torch.float32, device="cpu") + rgb_image = i.convert("RGB") + image = np.array(rgb_image).astype(np.float32) / 255.0 + image = torch.from_numpy(image)[None,] + + output_images.append(image) + output_masks.append(mask.unsqueeze(0)) + + if len(output_images) == 0: + raise ValueError("No valid frames found in the image") + + # All images concatenated (like original LoadImage behavior) + if len(output_images) > 1 and img.format not in excluded_formats: + all_images = torch.cat(output_images, dim=0) + all_masks = torch.cat(output_masks, dim=0) + else: + all_images = output_images[0] + all_masks = output_masks[0] + + # First frame and mask + first_frame = output_images[0] + first_mask = output_masks[0] + + # Last frame and mask + last_frame = output_images[-1] + last_mask = output_masks[-1] + + # Custom frame and mask + if custom_frame < len(output_images): + custom_frame_img = output_images[custom_frame] + custom_frame_mask = output_masks[custom_frame] + else: + # If custom_frame is out of range, default to last frame + custom_frame_img = output_images[-1] + custom_frame_mask = output_masks[-1] + + return (all_images, all_masks, first_frame, first_mask, last_frame, last_mask, custom_frame_img, custom_frame_mask) + + @classmethod + def IS_CHANGED(s, image, custom_frame): + image_path = folder_paths.get_annotated_filepath(image) + m = hashlib.sha256() + with open(image_path, 'rb') as f: + m.update(f.read()) + m.update(str(custom_frame).encode()) + return m.digest().hex() + + @classmethod + def VALIDATE_INPUTS(s, image, custom_frame): + if not folder_paths.exists_annotated_filepath(image): + return "Invalid image file: {}".format(image) + + return True + + +class SmartImagePaletteCreate: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "colors": ("INT", {"default": 5, "min": 1, "max": 5, "step": 1}), + "color_1": ("COLOR", {"default": "#000000"}), # Black + "color_2": ("COLOR", {"default": "#000000"}), # Black + "color_3": ("COLOR", {"default": "#000000"}), # Black + "color_4": ("COLOR", {"default": "#000000"}), # Black + "color_5": ("COLOR", {"default": "#000000"}), # Black + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("palette_image",) + FUNCTION = "create_palette" + CATEGORY = "SmartImageTools" + + def hex_to_rgb(self, hex_color): + """Convert hex color string to RGB tuple.""" + hex_color = hex_color.lstrip('#') + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + + def create_palette(self, colors, color_1, color_2, color_3, color_4, color_5): + """Create a palette image with specified colors.""" + # Collect all color inputs + color_inputs = [color_1, color_2, color_3, color_4, color_5] + + # Take only the number of colors specified + selected_colors = color_inputs[:colors] + + # Convert hex colors to RGB + rgb_colors = [] + for hex_color in selected_colors: + rgb = self.hex_to_rgb(hex_color) + rgb_colors.append(rgb) + + # Create palette image: 1 pixel height, colors pixels width + height = 1 + width = len(rgb_colors) + + # Create numpy array for the image + palette_img = np.zeros((height, width, 3), dtype=np.uint8) + + # Fill each pixel with the corresponding color + for i, rgb in enumerate(rgb_colors): + palette_img[0, i] = rgb + + # Convert to torch tensor and add batch dimension + palette_tensor = torch.from_numpy(palette_img).float() / 255.0 + palette_tensor = palette_tensor.unsqueeze(0) # Add batch dimension + + return (palette_tensor,) + + +class SmartBackgroundFill: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image": ("IMAGE",), + "background_color": (list(BG_COLOR_MAP.keys()), {"default": "white"}), + }, + "optional": { + "mask": ("MASK",), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "fill_background" + CATEGORY = "SmartImageTools" + + def fill_background(self, image, background_color, mask=None): + # Convert from tensor to numpy array + input_image = 255. * image.cpu().numpy() + batch_size = input_image.shape[0] + output_images = [] + + # Get background color RGB values (0-255 range) + if background_color == "transparent": + # For transparent, use black with alpha=0, but since we're filling background, + # this doesn't make sense - default to white + bg_rgb = (255, 255, 255) + else: + bg_rgb = BG_COLOR_MAP[background_color] + if bg_rgb is None: + # Fallback to white if color is not found + bg_rgb = (255, 255, 255) + + for i in range(batch_size): + img = input_image[i].copy() + channels = img.shape[2] if len(img.shape) > 2 else 0 + + if channels == 3: + # RGB image - pass through without modification + output_images.append(img) + elif channels == 4: + # RGBA image - process transparency and output RGB + h, w = img.shape[:2] + + # Get alpha channel - either from image or from mask + if mask is not None: + # Use mask's alpha values + # Mask: 0.0 = keep original (opaque), 1.0 = fill background (transparent) + # Convert to alpha: 0.0 = transparent, 1.0 = opaque + mask_np = mask[i].cpu().numpy() + alpha = 1.0 - mask_np # Invert mask values for proper alpha interpretation + else: + # Use image's alpha channel (0.0 = transparent, 1.0 = opaque) + alpha = img[:, :, 3] / 255.0 + + # Create RGB result image (always 3 channels) + result = np.zeros((h, w, 3), dtype=np.uint8) + + # For fully transparent pixels (alpha = 0), set to background color + transparent_mask = alpha == 0.0 + result[transparent_mask] = bg_rgb + + # For semi-transparent pixels, blend with background color + semi_transparent_mask = (alpha > 0.0) & (alpha < 1.0) + + if semi_transparent_mask.any(): + # Blend: result = (original * alpha) + (background * (1 - alpha)) + original_rgb = img[semi_transparent_mask, :3] + alpha_values = alpha[semi_transparent_mask] + + # Expand alpha and bg_rgb for broadcasting + alpha_expanded = alpha_values[:, np.newaxis] # Shape: [num_pixels, 1] + bg_rgb_expanded = np.array(bg_rgb)[np.newaxis, :] # Shape: [1, 3] + + # Perform blending + blended_rgb = original_rgb * alpha_expanded + bg_rgb_expanded * (1.0 - alpha_expanded) + + # Set blended RGB values back + result[semi_transparent_mask] = blended_rgb.astype(np.uint8) + + # For fully opaque pixels, copy original RGB + opaque_mask = alpha == 1.0 + result[opaque_mask] = img[opaque_mask, :3] + + output_images.append(result) + else: + # Unexpected number of channels - pass through + output_images.append(img) + + # Convert back to tensor + output_tensor = torch.from_numpy(np.stack(output_images) / 255.0).float() + + return (output_tensor,) + + +class SmartGetMask: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image": ("IMAGE",), + } + } + + RETURN_TYPES = ("MASK",) + FUNCTION = "get_mask" + CATEGORY = "SmartImageTools" + + def get_mask(self, image): + # Convert from tensor to numpy array + input_image = image.cpu().numpy() + batch_size = input_image.shape[0] + output_masks = [] + + for i in range(batch_size): + img = input_image[i] + channels = img.shape[2] if len(img.shape) > 2 else 0 + + if channels == 3: + # RGB image - create pure white mask (all 1.0) representing opaque areas + h, w = img.shape[:2] + mask = np.ones((h, w), dtype=np.float32) + output_masks.append(mask) + elif channels == 4: + # RGBA image - extract and invert alpha channel as mask + # Alpha channel is 0.0 (transparent) to 1.0 (opaque) + # Invert so transparent areas (0.0) become white (1.0) in mask + # And opaque areas (1.0) become black (0.0) in mask + alpha = img[:, :, 3] # Alpha channel + mask = 1.0 - alpha # Invert the mask + output_masks.append(mask) + else: + # Unexpected number of channels - create white mask as fallback + h, w = img.shape[:2] if len(img.shape) >= 2 else (64, 64) # Default size if unknown + mask = np.ones((h, w), dtype=np.float32) + output_masks.append(mask) + + # Convert to tensor with batch dimension [batch, height, width] + output_tensor = torch.from_numpy(np.stack(output_masks)) + + return (output_tensor,) + + +class SmartImagePadding: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image": ("IMAGE",), + "left": ("INT", {"default": 0, "min": 0, "max": 10000, "step": 1}), + "right": ("INT", {"default": 0, "min": 0, "max": 10000, "step": 1}), + "top": ("INT", {"default": 0, "min": 0, "max": 10000, "step": 1}), + "bottom": ("INT", {"default": 0, "min": 0, "max": 10000, "step": 1}), + "fill": (["Transparent", "Black", "White", "Grey", "Stretch"], {"default": "Transparent"}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "add_padding" + CATEGORY = "SmartImageTools" + + def add_padding(self, image, left, right, top, bottom, fill): + # Convert from tensor to numpy array + input_image = 255. * image.cpu().numpy() + batch_size = input_image.shape[0] + output_images = [] + + for i in range(batch_size): + img = input_image[i].copy() + h, w = img.shape[:2] + channels = img.shape[2] if len(img.shape) > 2 else 0 + + # Convert RGB to RGBA if needed + if channels == 3: + # Add alpha channel - fully opaque + alpha_channel = np.full((h, w, 1), 255, dtype=np.uint8) + img = np.concatenate([img, alpha_channel], axis=2) + elif channels != 4: + # Handle unexpected channel count - create RGBA with opaque alpha + rgba_img = np.zeros((h, w, 4), dtype=np.uint8) + if channels >= 3: + rgba_img[:, :, :3] = img[:, :, :3] + else: + # Grayscale or other - replicate to RGB + rgba_img[:, :, :3] = img[:, :, 0] if channels >= 1 else 255 + rgba_img[:, :, 3] = 255 # Fully opaque + img = rgba_img + + # Calculate new dimensions + new_h = h + top + bottom + new_w = w + left + right + + # Create new image with appropriate fill + if fill == "Transparent": + padded_img = np.zeros((new_h, new_w, 4), dtype=np.uint8) + elif fill == "Black": + padded_img = np.zeros((new_h, new_w, 4), dtype=np.uint8) + padded_img[:, :, 3] = 255 # Fully opaque alpha + elif fill == "White": + padded_img = np.full((new_h, new_w, 4), 255, dtype=np.uint8) + elif fill == "Grey": + padded_img = np.full((new_h, new_w, 4), 128, dtype=np.uint8) + padded_img[:, :, 3] = 255 # Fully opaque alpha + elif fill == "Stretch": + padded_img = np.zeros((new_h, new_w, 4), dtype=np.uint8) + # Fill with stretched edge pixels + # Top padding + if top > 0: + for y in range(top): + padded_img[y, left:left + w] = img[0, :] + # Bottom padding + if bottom > 0: + for y in range(top + h, new_h): + padded_img[y, left:left + w] = img[h - 1, :] + # Left padding + if left > 0: + for x in range(left): + padded_img[top:top + h, x] = img[:, 0] + # Right padding + if right > 0: + for x in range(left + w, new_w): + padded_img[top:top + h, x] = img[:, w - 1] + # Fill corners + if top > 0 and left > 0: + padded_img[:top, :left] = img[0, 0] + if top > 0 and right > 0: + padded_img[:top, left + w:] = img[0, w - 1] + if bottom > 0 and left > 0: + padded_img[top + h:, :left] = img[h - 1, 0] + if bottom > 0 and right > 0: + padded_img[top + h:, left + w:] = img[h - 1, w - 1] + + # Copy original image to the center (accounting for padding) + padded_img[top:top + h, left:left + w] = img + + output_images.append(padded_img) + + # Convert back to tensor + output_tensor = torch.from_numpy(np.stack(output_images) / 255.0).float() + + return (output_tensor,) + + +class SmartGradientDeformation: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image": ("IMAGE",), + "type": (["vertical", "horizontal"],), + "start_pos": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "end_pos": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "offset": ("INT", {"default": 20, "min": -8192, "max": 8192, "step": 1}), + "exponent": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 10.0, "step": 0.01}), + } + } + + RETURN_TYPES = ("IMAGE", "IMAGE") + RETURN_NAMES = ("image", "gradient") + FUNCTION = "deform" + CATEGORY = "SmartImageTools" + + def deform(self, image, type, start_pos, end_pos, offset, exponent): + if start_pos > end_pos: + start_pos, end_pos = end_pos, start_pos + + batch_size, height, width, _ = image.shape + + deformed_images = [] + gradient_images = [] + + for i in range(batch_size): + img_tensor = image[i] + img_np = img_tensor.cpu().numpy() + + if type == "vertical": + size = height + else: + size = width + + start = int(start_pos * size) + end = int(end_pos * size) + + grad_1d = np.zeros((size,), dtype=np.float32) + + if start < end: + ramp = np.linspace(0, 1, num=end - start, dtype=np.float32) + grad_1d[start:end] = offset * (ramp ** exponent) + + if end < size: + grad_1d[end:] = offset + + map_x, map_y = np.meshgrid(np.arange(width, dtype=np.float32), np.arange(height, dtype=np.float32)) + + if type == "vertical": + displacement = grad_1d.reshape(-1, 1) + map_y = map_y - displacement + gradient_map = np.tile(displacement, (1, width)) + else: + map_x = map_x - grad_1d + gradient_map = np.tile(grad_1d, (height, 1)) + + deformed_img_np = cv2.remap(img_np, map_x, map_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE) + + min_val, max_val = np.min(gradient_map), np.max(gradient_map) + if max_val == min_val: + gradient_map_norm = np.zeros_like(gradient_map, dtype=np.float32) + else: + gradient_map_norm = (gradient_map - min_val) / (max_val - min_val) + + gradient_img_np = np.stack([gradient_map_norm] * 3, axis=-1) + + deformed_images.append(torch.from_numpy(deformed_img_np)) + gradient_images.append(torch.from_numpy(gradient_img_np.astype(np.float32))) + + deformed_batch = torch.stack(deformed_images) + gradient_batch = torch.stack(gradient_images) + + return (deformed_batch, gradient_batch) + NODE_CLASS_MAPPINGS = { "SmartImagePaletteConvert": SmartImagePaletteConvert, "SmartImagesProcessor": SmartImagesProcessor, @@ -2051,7 +2683,13 @@ NODE_CLASS_MAPPINGS = { "SmartVideoPreviewScaled": SmartVideoPreviewScaled, "SmartSaveAnimatedPNG": SmartSaveAnimatedPNG, "SmartSavePNG": SmartSavePNG, - "SmartDrawPoints": SmartDrawPoints + "SmartDrawPoints": SmartDrawPoints, + "SmartLoadGIFImage": SmartLoadGIFImage, + "SmartImagePaletteCreate": SmartImagePaletteCreate, + "SmartBackgroundFill": SmartBackgroundFill, + "SmartGetMask": SmartGetMask, + "SmartImagePadding": SmartImagePadding, + "SmartGradientDeformation": SmartGradientDeformation } NODE_DISPLAY_NAME_MAPPINGS = { @@ -2071,5 +2709,11 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SmartVideoPreviewScaled": "Smart Video Preview Scaled", "SmartSaveAnimatedPNG": "Smart Save Animated Image", "SmartSavePNG": "Smart Save PNG", - "SmartDrawPoints": "Smart Draw Points" + "SmartDrawPoints": "Smart Draw Points", + "SmartLoadGIFImage": "Smart Load GIF Image", + "SmartImagePaletteCreate": "Smart Image Palette Create", + "SmartBackgroundFill": "Smart Background Fill", + "SmartGetMask": "Smart Get Mask", + "SmartImagePadding": "Smart Image Padding", + "SmartGradientDeformation": "Smart Gradient Deformation" } \ No newline at end of file