From 540095cb8af7e8b4e0acf814c2dcba052ba487ff Mon Sep 17 00:00:00 2001 From: Fill Date: Thu, 23 Oct 2025 15:25:46 -0700 Subject: [PATCH] added image overlay node --- __init__.py | 3 + nodes/image/FL_ImageOverlay.py | 268 +++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 3 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 nodes/image/FL_ImageOverlay.py diff --git a/__init__.py b/__init__.py index ea5f8f2..f07e1a0 100644 --- a/__init__.py +++ b/__init__.py @@ -103,6 +103,7 @@ from .nodes.image.FL_ImageBatch import FL_ImageBatch from .nodes.image.FL_ImageBatchListConverter import FL_ImageListToImageBatch, FL_ImageBatchToImageList from .nodes.image.FL_ImageBatchToGrid import FL_ImageBatchToGrid from .nodes.image.FL_ImageNotes import FL_ImageNotes +from .nodes.image.FL_ImageOverlay import FL_ImageOverlay from .nodes.image.FL_ImageSelector import FL_ImageSelector from .nodes.image.FL_ImageSlicer import FL_ImageSlicer from .nodes.image.FL_Image_AddToBatch import FL_ImageAddToBatch @@ -307,6 +308,7 @@ NODE_CLASS_MAPPINGS = { "FL_Math": FL_Math, "FL_ImageSlicer": FL_ImageSlicer, "FL_ImageSelector": FL_ImageSelector, + "FL_ImageOverlay": FL_ImageOverlay, "FL_ImageAspectCropper": FL_ImageAspectCropper, "FL_HF_UploaderAbsolute": FL_HF_UploaderAbsolute, "FL_ImageListToImageBatch": FL_ImageListToImageBatch, @@ -486,6 +488,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "FL_Math": "FL Math", "FL_ImageSlicer": "FL Image Slicer", "FL_ImageSelector": "FL Image Selector", + "FL_ImageOverlay": "FL Image Overlay", "FL_ImageAspectCropper": "FL Image Aspect Cropper", "FL_HF_UploaderAbsolute": "FL HF Uploader Absolute", "FL_ImageListToImageBatch": "FL Image List To Image Batch", diff --git a/nodes/image/FL_ImageOverlay.py b/nodes/image/FL_ImageOverlay.py new file mode 100644 index 0000000..2a75492 --- /dev/null +++ b/nodes/image/FL_ImageOverlay.py @@ -0,0 +1,268 @@ +import torch +import numpy as np +from PIL import Image, ImageFilter +from ..utils import tensor_to_pil, pil_to_tensor + + +class FL_ImageOverlay: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "base_image": ("IMAGE",), + "overlay_image": ("IMAGE",), + "mask": ("MASK",), + "x_offset": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}), + "y_offset": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}), + "alignment": ([ + "custom", + "center", + "top-left", + "top-center", + "top-right", + "center-left", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], {"default": "custom"}), + "resize_overlay": ([ + "none", + "fit_to_base", + "scale_50%", + "scale_75%", + "scale_125%", + "scale_150%", + "scale_200%" + ], {"default": "none"}), + "blend_mode": ([ + "normal", + "multiply", + "screen", + "overlay", + "add" + ], {"default": "normal"}), + "opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "invert_mask": ("BOOLEAN", {"default": False}), + "mask_feather": ("INT", {"default": 0, "min": 0, "max": 100, "step": 1}), + "boundary_behavior": (["clip", "extend_canvas"], {"default": "clip"}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "overlay_images" + CATEGORY = "🏵️Fill Nodes/Image" + + def overlay_images(self, base_image, overlay_image, mask, x_offset, y_offset, + alignment, resize_overlay, blend_mode, opacity, invert_mask, + mask_feather, boundary_behavior): + # Process batch - use first image from each batch + base_pil = tensor_to_pil(base_image, batch_index=0) + overlay_pil = tensor_to_pil(overlay_image, batch_index=0) + + # Convert mask tensor to PIL (masks are typically [B, H, W]) + if len(mask.shape) == 3: + mask_np = mask[0].cpu().numpy() + elif len(mask.shape) == 2: + mask_np = mask.cpu().numpy() + else: + raise ValueError(f"Unexpected mask shape: {mask.shape}") + + # Convert to 0-255 range and create PIL image + mask_pil = Image.fromarray((mask_np * 255).astype(np.uint8), mode='L') + + # Resize mask to match overlay dimensions + if mask_pil.size != overlay_pil.size: + mask_pil = mask_pil.resize(overlay_pil.size, Image.Resampling.LANCZOS) + + # Invert mask if requested + if invert_mask: + mask_pil = Image.eval(mask_pil, lambda x: 255 - x) + + # Apply feathering to mask + if mask_feather > 0: + mask_pil = mask_pil.filter(ImageFilter.GaussianBlur(radius=mask_feather)) + + # Resize overlay if requested + overlay_pil = self.resize_overlay_image(overlay_pil, base_pil, resize_overlay, mask_pil) + + # Recalculate mask size after resize + if mask_pil.size != overlay_pil.size: + mask_pil = mask_pil.resize(overlay_pil.size, Image.Resampling.LANCZOS) + + # Calculate position based on alignment + x_pos, y_pos = self.calculate_position( + base_pil.size, overlay_pil.size, x_offset, y_offset, alignment + ) + + # Perform the compositing + result_pil = self.composite_images( + base_pil, overlay_pil, mask_pil, x_pos, y_pos, + blend_mode, opacity, boundary_behavior + ) + + # Convert back to tensor + result_tensor = pil_to_tensor(result_pil) + + return (result_tensor,) + + def resize_overlay_image(self, overlay, base, resize_mode, mask): + """Resize the overlay image based on the selected mode""" + if resize_mode == "none": + return overlay + + base_w, base_h = base.size + overlay_w, overlay_h = overlay.size + + if resize_mode == "fit_to_base": + # Resize overlay to match base dimensions + new_size = (base_w, base_h) + else: + # Extract scale percentage + scale_map = { + "scale_50%": 0.5, + "scale_75%": 0.75, + "scale_125%": 1.25, + "scale_150%": 1.5, + "scale_200%": 2.0 + } + scale = scale_map.get(resize_mode, 1.0) + new_size = (int(overlay_w * scale), int(overlay_h * scale)) + + resized_overlay = overlay.resize(new_size, Image.Resampling.LANCZOS) + return resized_overlay + + def calculate_position(self, base_size, overlay_size, x_offset, y_offset, alignment): + """Calculate the position to place the overlay based on alignment""" + base_w, base_h = base_size + overlay_w, overlay_h = overlay_size + + # Alignment presets + if alignment == "center": + x = (base_w - overlay_w) // 2 + y = (base_h - overlay_h) // 2 + elif alignment == "top-left": + x, y = 0, 0 + elif alignment == "top-center": + x = (base_w - overlay_w) // 2 + y = 0 + elif alignment == "top-right": + x = base_w - overlay_w + y = 0 + elif alignment == "center-left": + x = 0 + y = (base_h - overlay_h) // 2 + elif alignment == "center-right": + x = base_w - overlay_w + y = (base_h - overlay_h) // 2 + elif alignment == "bottom-left": + x = 0 + y = base_h - overlay_h + elif alignment == "bottom-center": + x = (base_w - overlay_w) // 2 + y = base_h - overlay_h + elif alignment == "bottom-right": + x = base_w - overlay_w + y = base_h - overlay_h + else: # custom + x, y = 0, 0 + + # Apply offsets + x += x_offset + y += y_offset + + return x, y + + def composite_images(self, base, overlay, mask, x_pos, y_pos, blend_mode, opacity, boundary_behavior): + """Composite the overlay onto the base image""" + base_w, base_h = base.size + overlay_w, overlay_h = overlay.size + + # Handle boundary behavior + if boundary_behavior == "extend_canvas": + # Calculate required canvas size + canvas_w = max(base_w, x_pos + overlay_w, abs(min(0, x_pos)) + base_w) + canvas_h = max(base_h, y_pos + overlay_h, abs(min(0, y_pos)) + base_h) + + # Create extended canvas + canvas = Image.new('RGB', (canvas_w, canvas_h), (0, 0, 0)) + + # Paste base image at appropriate position + base_x = abs(min(0, x_pos)) + base_y = abs(min(0, y_pos)) + canvas.paste(base, (base_x, base_y)) + + # Adjust overlay position for extended canvas + overlay_x = x_pos if x_pos >= 0 else 0 + overlay_y = y_pos if y_pos >= 0 else 0 + + result = canvas.copy() + else: # clip + result = base.copy() + overlay_x = x_pos + overlay_y = y_pos + + # Calculate visible region of overlay + src_x = max(0, -x_pos) + src_y = max(0, -y_pos) + dst_x = max(0, x_pos) + dst_y = max(0, y_pos) + + # Calculate dimensions of visible region + visible_w = min(overlay_w - src_x, base_w - dst_x) + visible_h = min(overlay_h - src_y, base_h - dst_y) + + # If overlay is completely outside bounds, return base image + if visible_w <= 0 or visible_h <= 0: + return base + + # Crop overlay and mask to visible region + overlay = overlay.crop((src_x, src_y, src_x + visible_w, src_y + visible_h)) + mask = mask.crop((src_x, src_y, src_x + visible_w, src_y + visible_h)) + overlay_x = dst_x + overlay_y = dst_y + + # Apply blend mode + if blend_mode != "normal": + overlay = self.apply_blend_mode(result, overlay, blend_mode, overlay_x, overlay_y) + + # Apply global opacity to mask + if opacity < 1.0: + mask_np = np.array(mask).astype(np.float32) + mask_np = (mask_np * opacity).astype(np.uint8) + mask = Image.fromarray(mask_np, mode='L') + + # Composite using the mask + result.paste(overlay, (overlay_x, overlay_y), mask) + + return result + + def apply_blend_mode(self, base, overlay, mode, x_pos, y_pos): + """Apply blend mode to overlay based on the underlying base image region""" + # Extract the region from base that overlay will cover + overlay_w, overlay_h = overlay.size + base_region = base.crop((x_pos, y_pos, x_pos + overlay_w, y_pos + overlay_h)) + + # Convert to numpy arrays for blending + base_np = np.array(base_region).astype(np.float32) / 255.0 + overlay_np = np.array(overlay).astype(np.float32) / 255.0 + + # Apply blend mode + if mode == "multiply": + result_np = base_np * overlay_np + elif mode == "screen": + result_np = 1 - (1 - base_np) * (1 - overlay_np) + elif mode == "overlay": + # Overlay blend mode + mask = base_np < 0.5 + result_np = np.where(mask, + 2 * base_np * overlay_np, + 1 - 2 * (1 - base_np) * (1 - overlay_np)) + elif mode == "add": + result_np = np.clip(base_np + overlay_np, 0, 1) + else: # normal + result_np = overlay_np + + # Convert back to PIL + result_np = (result_np * 255).astype(np.uint8) + return Image.fromarray(result_np, mode='RGB') diff --git a/pyproject.toml b/pyproject.toml index 8155450..719b9cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui_fill-nodes" description = "Fill-Nodes is a versatile collection of custom nodes for ComfyUI that extends functionality across multiple domains. Features include advanced image processing (pixelation, slicing, masking), visual effects generation (glitch, halftone, pixel art), comprehensive file handling (PDF creation/extraction, Google Drive integration), AI model interfaces (GPT, DALL-E, Hugging Face), utility nodes for workflow enhancement, and specialized tools for video processing, captioning, and batch operations. The pack provides both practical workflow solutions and creative tools within a unified node collection." -version = "2.0.2" +version = "2.0.3" license = "LICENSE" dependencies = ["diffusers", "librosa", "sounddevice", "glitch_this", "PyOpenGL", "glfw", "scipy>=1.13.1", "requests", "aiohttp", "moviepy", "matplotlib", "reportlab", "openai", "PyPDF2", "pdf2image", "PyMuPDF", "reportlab", "PyPDF2", "ollama", "kornia", "opencv-python", "gdown", "open_clip_torch", "google-genai"]