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:
co-authored by
Claude Opus 4.6
parent
f41b1c9c96
commit
290d6c241e
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user