feat: add FL_ImageReplace node for batch image/mask replacement (v2.3.6)

New node that replaces images or masks at a specific index within a batch.
Includes a grid preview with green border highlighting on replaced positions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fillip
2026-04-01 17:52:36 -07:00
co-authored by Claude Opus 4.6
parent f41b1c9c96
commit 290d6c241e
4 changed files with 288 additions and 1 deletions
+3
View File
@@ -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",
+195
View File
@@ -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}"
+1 -1
View File
@@ -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"]
+89
View File
@@ -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);
};
}
}
}
});