diff --git a/__init__.py b/__init__.py index e1ab264..f38172a 100644 --- a/__init__.py +++ b/__init__.py @@ -116,6 +116,7 @@ from .nodes.image.FL_ImageBatchListConverter import FL_ImageListToImageBatch, FL 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_ImageReplace import FL_ImageReplace from .nodes.image.FL_ImageSelector import FL_ImageSelector from .nodes.image.FL_ImagePicker import FL_ImagePicker from .nodes.image.FL_ImageSlicer import FL_ImageSlicer @@ -332,6 +333,7 @@ NODE_CLASS_MAPPINGS = { "FL_ImageSelector": FL_ImageSelector, "FL_ImagePicker": FL_ImagePicker, "FL_ImageOverlay": FL_ImageOverlay, + "FL_ImageReplace": FL_ImageReplace, "FL_ImageAspectCropper": FL_ImageAspectCropper, "FL_HF_UploaderAbsolute": FL_HF_UploaderAbsolute, "FL_ImageListToImageBatch": FL_ImageListToImageBatch, @@ -520,6 +522,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "FL_ImageSelector": "FL Image Selector", "FL_ImagePicker": "FL Image Picker", "FL_ImageOverlay": "FL Image Overlay", + "FL_ImageReplace": "FL Image Replace", "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_ImageReplace.py b/nodes/image/FL_ImageReplace.py new file mode 100644 index 0000000..1f55591 --- /dev/null +++ b/nodes/image/FL_ImageReplace.py @@ -0,0 +1,195 @@ +import torch +import torch.nn.functional as F +import math +import numpy as np +import io +import base64 +from PIL import Image, ImageDraw +from server import PromptServer + + +class FL_ImageReplace: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + "replacement": ("IMAGE",), + "index": ("INT", {"default": 0, "min": -100, "max": 9999, "step": 1}), + "show_preview": ("BOOLEAN", {"default": False, "label": "Show Preview on Node"}), + }, + "optional": { + "masks": ("MASK",), + "replacement_mask": ("MASK",), + }, + } + + RETURN_TYPES = ("IMAGE", "MASK") + RETURN_NAMES = ("images", "masks") + FUNCTION = "replace_in_batch" + OUTPUT_NODE = True + CATEGORY = "🏵️Fill Nodes/Image" + + def replace_in_batch(self, images, replacement, index, show_preview=False, + masks=None, replacement_mask=None): + # Ensure batch dimensions exist + if images.dim() == 3: + images = images.unsqueeze(0) + if replacement.dim() == 3: + replacement = replacement.unsqueeze(0) + + batch_size = images.shape[0] + + if batch_size == 0: + result_masks = masks if masks is not None else torch.ones(0, device=images.device) + return (images, result_masks) + + # Resolve negative index and clamp + if index < 0: + index = batch_size + index + index = max(0, min(index, batch_size - 1)) + + B, H, W, C = images.shape + N, rH, rW, rC = replacement.shape + + # Ensure replacement is on the same device + replacement = replacement.to(images.device) + + # Handle channel mismatch + if rC != C: + if rC < C: + padding = torch.ones(N, rH, rW, C - rC, device=replacement.device) + replacement = torch.cat([replacement, padding], dim=-1) + else: + replacement = replacement[..., :C] + + # Resize replacement if spatial dimensions don't match + if rH != H or rW != W: + replacement_chw = replacement.permute(0, 3, 1, 2) + is_upscaling = (H > rH) or (W > rW) + mode = 'bicubic' if is_upscaling else 'area' + replacement_chw = F.interpolate( + replacement_chw, + size=(H, W), + mode=mode, + align_corners=False if mode in ['bicubic', 'bilinear'] else None + ) + replacement = replacement_chw.permute(0, 2, 3, 1) + replacement = replacement.clamp(0.0, 1.0) + + # Truncate replacement if it exceeds remaining space + available = batch_size - index + if N > available: + replacement = replacement[:available] + N = available + + # Perform the replacement + result = images.clone() + result[index:index + N] = replacement + + # Track which indices were replaced + replaced_indices = set(range(index, index + N)) + + # Handle masks + result_masks = self._handle_masks( + masks, replacement_mask, batch_size, index, N, H, W, images.device + ) + + # Send preview if enabled + if show_preview: + preview_image = self._create_preview_grid(result, replaced_indices) + display_data = self._prepare_image_for_display(preview_image) + PromptServer.instance.send_sync("fl_image_replace", {"image": display_data}) + + return (result, result_masks) + + def _handle_masks(self, masks, replacement_mask, batch_size, index, N, H, W, device): + if masks is not None: + if masks.dim() == 2: + masks = masks.unsqueeze(0) + result_masks = masks.clone() + tH, tW = masks.shape[1], masks.shape[2] + elif replacement_mask is not None: + result_masks = torch.ones(batch_size, H, W, device=device) + tH, tW = H, W + else: + return torch.ones(batch_size, H, W, device=device) + + if replacement_mask is not None: + if replacement_mask.dim() == 2: + replacement_mask = replacement_mask.unsqueeze(0) + + replacement_mask = replacement_mask.to(device) + mN, mH, mW = replacement_mask.shape + + # Resize if needed + if mH != tH or mW != tW: + replacement_mask = F.interpolate( + replacement_mask.unsqueeze(1), size=(tH, tW), mode='nearest' + ).squeeze(1) + + # Truncate if needed + available = batch_size - index + if mN > available: + replacement_mask = replacement_mask[:available] + mN = available + + result_masks[index:index + mN] = replacement_mask + + return result_masks + + def _create_preview_grid(self, images, replaced_indices, max_preview_size=512, border_width=3): + B, H, W, C = images.shape + + # Calculate grid layout + images_per_row = math.ceil(math.sqrt(B)) + num_rows = math.ceil(B / images_per_row) + + # Calculate thumbnail size so grid fits within max_preview_size + cell_w = max_preview_size // images_per_row + cell_h = max_preview_size // num_rows + scale = min(cell_w / W, cell_h / H) + thumb_w = max(1, int(W * scale)) + thumb_h = max(1, int(H * scale)) + + # Create grid canvas + grid_w = thumb_w * images_per_row + grid_h = thumb_h * num_rows + grid_image = Image.new('RGB', (grid_w, grid_h), (0, 0, 0)) + draw = ImageDraw.Draw(grid_image) + + for i in range(B): + row = i // images_per_row + col = i % images_per_row + + # Convert tensor to PIL thumbnail + img_np = (images[i].cpu().numpy() * 255).astype('uint8') + if img_np.shape[-1] == 4: + img_np = img_np[..., :3] + pil_img = Image.fromarray(img_np) + pil_img = pil_img.resize((thumb_w, thumb_h), Image.Resampling.LANCZOS) + + x = col * thumb_w + y = row * thumb_h + grid_image.paste(pil_img, (x, y)) + + # Draw green border on replaced images + if i in replaced_indices: + for b in range(border_width): + draw.rectangle( + [x + b, y + b, x + thumb_w - 1 - b, y + thumb_h - 1 - b], + outline=(0, 255, 0) + ) + + return grid_image + + def _prepare_image_for_display(self, pil_image): + """Convert PIL image to base64 for frontend display.""" + display_img = pil_image.copy() + max_size = (512, 512) + display_img.thumbnail(max_size, Image.Resampling.LANCZOS) + + buffered = io.BytesIO() + display_img.save(buffered, format="PNG") + img_str = base64.b64encode(buffered.getvalue()).decode() + return f"data:image/png;base64,{img_str}" diff --git a/pyproject.toml b/pyproject.toml index fca1c12..997167e 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.3.5" +version = "2.3.6" license = "LICENSE" dependencies = ["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"] diff --git a/web/nodes/image/FL_ImageReplace.js b/web/nodes/image/FL_ImageReplace.js new file mode 100644 index 0000000..ee0a446 --- /dev/null +++ b/web/nodes/image/FL_ImageReplace.js @@ -0,0 +1,89 @@ +import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; + +app.registerExtension({ + name: "FL.ImageReplace", + async nodeCreated(node) { + if (node.comfyClass === "FL_ImageReplace") { + const MIN_WIDTH = 250; + const MIN_HEIGHT_WITH_PREVIEW = 500; + const MIN_HEIGHT_WITHOUT_PREVIEW = 200; + const PADDING = 10; + + const img = new Image(); + img.onload = () => node.setDirtyCanvas(true); + + node.onDrawBackground = function(ctx) { + if (!this.flags.collapsed) { + const showPreviewWidget = this.widgets?.find(w => w.name === "show_preview"); + const showPreview = showPreviewWidget ? showPreviewWidget.value : false; + + if (!showPreview || !img.src) { + return; + } + + const [w, h] = this.size; + + const lastWidget = node.widgets[node.widgets.length - 1]; + const lastWidgetY = lastWidget.last_y || 0; + const IMAGE_Y_OFFSET = lastWidgetY + 30; + const imageArea = h - IMAGE_Y_OFFSET - PADDING; + + if (img.src && imageArea > 50) { + const aspectRatio = img.width / img.height; + let drawWidth = w - 2 * PADDING; + let drawHeight = imageArea; + + if (drawWidth / drawHeight > aspectRatio) { + drawWidth = drawHeight * aspectRatio; + } else { + drawHeight = drawWidth / aspectRatio; + } + + const x = PADDING + (w - 2 * PADDING - drawWidth) / 2; + const y = IMAGE_Y_OFFSET; + + ctx.drawImage(img, x, y, drawWidth, drawHeight); + } + } + }; + + api.addEventListener("fl_image_replace", (event) => { + if (event.detail.image) { + img.src = event.detail.image; + } + }); + + function updateNodeSize() { + const showPreviewWidget = node.widgets?.find(w => w.name === "show_preview"); + const showPreview = showPreviewWidget ? showPreviewWidget.value : false; + const minHeight = showPreview ? MIN_HEIGHT_WITH_PREVIEW : MIN_HEIGHT_WITHOUT_PREVIEW; + + node.size[0] = Math.max(MIN_WIDTH, node.size[0]); + node.size[1] = Math.max(minHeight, node.size[1]); + } + + node.onResize = updateNodeSize; + updateNodeSize(); + + const showPreviewWidget = node.widgets?.find(w => w.name === "show_preview"); + if (showPreviewWidget) { + const originalCallback = showPreviewWidget.callback; + showPreviewWidget.callback = function(value) { + if (originalCallback) { + originalCallback.apply(this, arguments); + } + + if (!value) { + node.size[1] = MIN_HEIGHT_WITHOUT_PREVIEW; + } else { + node.size[1] = MIN_HEIGHT_WITH_PREVIEW; + } + + updateNodeSize(); + node.setDirtyCanvas(true); + }; + } + } + } +});