Files
by-ae-ae-in-workflow/__init__.py
T
by-aeandCursor Agent 63d55f827c fix: add input validation for CropImageBatchToMaskBounds node
- Validate images and masks are torch.Tensor objects
- Validate images is 4D (B, H, W, C) and masks is 3D (B, H, W)
- Validate images and masks have matching batch sizes
- Validate images and masks have matching spatial dimensions
- Add comprehensive tests for all validation error cases

Previously, shape mismatches caused cryptic IndexError messages.
Now users get clear, actionable error messages.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-10-25 21:01:33 +00:00

990 lines
40 KiB
Python

"""
ae-in-workflow - In-workflow Nodes for ComfyUI
==============================================
A collection of interactive nodes that provide heavy interaction and streaming capabilities for ComfyUI workflows.
Main nodes:
- Z-Image - Images To LoRA: Convert image batches to LoRA using DiffSynth-Studio's Z-Image pipeline with memory-efficient processing
- Image Selector: Interactive image selection from folders with thumbnail grid view and batch processing
- Pose Editor (Interactive): Professional pose manipulation with multi-person support, hierarchical editing, undo/redo, and intelligent caching
Utils:
- Interpolate Float List: Process and interpolate comma-separated float lists
"""
import os
import sys
import torch
import numpy as np
from PIL import Image
import hashlib
import json
# The famous `anytyp` trick for ComfyUI - Sorry I forget who did this originally
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
any_typ = AnyType("*")
# Add the current directory to sys.path so we can import pose_editor
current_dir = os.path.dirname(os.path.abspath(__file__))
if current_dir not in sys.path:
sys.path.insert(0, current_dir)
# Import our pose editor
from pose_editor import pose_editor
# Import Z-Image functions
from zimage_i2l import process_images_to_lora, ZIMAGE_AVAILABLE, DIFF_SYNTH_AVAILABLE, DEPENDENCIES_AVAILABLE
# Import Image Selector functions
from image_selector import image_selector, hex_to_rgb
# Import model management and garbage collection
try:
import comfy.model_management as model_management
except ImportError:
# Allow import without ComfyUI for testing
model_management = None
import gc
# ComfyUI node class
class PoseEditorNodeAE:
"""
Interactive Pose Editor Node - Professional pose manipulation for ComfyUI
Advanced pose editing with hierarchical control, undo/redo, and multi-person support.
Key Features:
- Individual keypoint editing with click and drag
- Hierarchical manipulation (move keypoints with their children)
- Bone-level rotation (rotate limbs around joints)
- Multi-person scene editing with automatic spacing
- Complete undo/redo system
- Intelligent caching for fast subsequent edits
- Scale-aware keypoint fixing using anatomical proportions
- Pose duplication, deletion, and transformation tools
- Real-time visual feedback with professional UI
"""
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"POSE_KEYPOINT_optional": (any_typ, {"tooltip": "OpenPose format pose data with keypoints"}),
"padding": ("INT", {
"default": 128,
"min": 0,
"max": 512,
"step": 1,
"tooltip": "Padding around poses in output image (pixels)"
}),
"seed": ("INT", {
"default": 0,
"min": 0,
"max": 0xffffffffffffffff,
"step": 1,
"tooltip": "Change this value to force the node to run again with the same pose data"
}),
"reset_cached_window_position": ("BOOLEAN", {
"default": False,
"tooltip": "Usually False, but if you can't find the window set to True to reset.",
}),
}
}
RETURN_TYPES = ("IMAGE", any_typ)
RETURN_NAMES = ("image", "edited_pose_data")
OUTPUT_TOOLTIPS = (
"Rendered pose visualization image",
"Modified pose data in OpenPose format with user edits applied"
)
FUNCTION = "edit_pose"
CATEGORY = "ae-in-workflow"
def edit_pose(self, POSE_KEYPOINT_optional=None, padding=128, seed=0, reset_cached_window_position=False):
"""
Launch the interactive pose editor
Args:
POSE_KEYPOINT: (Optional) OpenPose format pose data with keypoints, if not provided, a default T-pose will be used
padding: Padding around poses in output image (pixels)
reset_cached_window_position: Whether to reset cached window position to defaults
Returns:
tuple: (image_tensor, edited_pose_data)
"""
try:
# Convert pose_data to the format expected by pose_editor
if isinstance(POSE_KEYPOINT_optional, dict):
input_data = [POSE_KEYPOINT_optional]
else:
input_data = POSE_KEYPOINT_optional
# Call the pose editor
image_tensor, edited_pose_data = pose_editor(input_data, padding=padding, reset_cached_window_position=reset_cached_window_position)
return (image_tensor, edited_pose_data)
except Exception as e:
print(f"Pose Editor Error: {e}")
import traceback
traceback.print_exc()
# Return a minimal fallback
fallback_tensor = torch.zeros(1, 64, 64, 3)
return (fallback_tensor, POSE_KEYPOINT_optional)
# ComfyUI node class for Z-Image - Images To LoRA conversion
class ZImageImagesToLoRANodeAE:
"""
This node takes a batch of images and converts them into a LoRA (Low-Rank Adaptation)
using the Z-Image i2L pipeline from DiffSynth-Studio.
Powered by DiffSynth-Studio: https://github.com/modelscope/DiffSynth-Studio
The generated LoRA files are saved in: models/loras/Z-Image/ae/z-image_<name>vXXX.safetensors
where XXX is an auto-incrementing version number.
Key Features:
- Memory-efficient processing with VRAM management
- Batch processing for large image sets
- Automatic version numbering to avoid overwrites
- Returns relative path compatible with LoraLoaderModelOnly node
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": (any_typ, {"tooltip": "Batch of images as tensor (shape: B,H,W,3) to convert to LoRA"}),
"lora_name": ("STRING", {
"default": "my_lora",
"tooltip": "Name for the LoRA dataset (will be sanitized for filename)"
}),
},
"optional": {
"batch_size": ("INT", {
"default": 8,
"min": 1,
"max": 64,
"step": 1,
"tooltip": "Number of images to process simultaneously (higher = faster but more VRAM)"
}),
"seed": ("INT", {
"default": 0,
"min": 0,
"max": 0xffffffffffffffff,
"step": 1,
"tooltip": "Change this value to force the node to run again with the same inputs"
}),
"unload_models_before_running": ("BOOLEAN", {
"default": True,
"tooltip": "Unload all models before running the node"
}),
"lora_weights": ("STRING", {
"default": "",
"tooltip": "Comma-separated weights for each image (e.g., '1.0,2.0,0.5'). Leave empty for equal weighting."
}),
"normalize_saved_lora_strength_to": ("FLOAT", {
"default": 1.00,
"min": 0.01,
"max": 5.00,
"step": 0.01,
"tooltip": "Identical behavior to setting LoRA Loader strength (use that for dialing in first).\nYour LoRA only works at 1.75 strength? No problem, set this to 1.75 and it will be the new 1.0"
}),
"reduce_size_factor": ("INT", {
"default": 1,
"min": 1,
"max": 16,
"step": 1,
"tooltip": "Batch size for averaging LoRAs (higher = smaller final LoRA by averaging groups)"
}),
}
}
RETURN_TYPES = (any_typ,)
RETURN_NAMES = ("lora_path",)
OUTPUT_TOOLTIPS = (
"Relative path to the generated LoRA file (compatible with LoraLoaderModelOnly)",
)
FUNCTION = "images_to_lora"
CATEGORY = "ae-in-workflow"
def images_to_lora(self, images, lora_name, batch_size=8, lora_weights="", normalize_saved_lora_strength_to=1.0, reduce_size_factor=1, seed=0, unload_models_before_running=True):
"""
Convert images to LoRA using Z-Image pipeline
Args:
images: Tensor of images (B, H, W, 3) to convert to LoRA
lora_name: Name for the LoRA dataset
batch_size: Number of images to process simultaneously
lora_weights: List of weights for each image (for weighted averaging)
normalize_saved_lora_strength_to: Target strength normalization for saved LoRA
reduce_size_factor: Batch size for averaging LoRAs (reduces final size)
Returns:
str: Relative path to the generated LoRA file
"""
if not DIFF_SYNTH_AVAILABLE:
raise ImportError("DiffSynth-Studio is not installed yet.\nSimply follow the instructions at https://github.com/modelscope/DiffSynth-Studio\nRead the github for more info: https://github.com/by-ae/ae-in-workflow")
if not DEPENDENCIES_AVAILABLE:
raise ImportError("You need to run `pip install -r requirements.txt` to install the required dependencies.\nRead the github for more info: https://github.com/by-ae/ae-in-workflow")
if model_management is not None:
model_management.unload_all_models()
model_management.soft_empty_cache(True)
gc.collect()
try:
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
except:
pass
try:
# Validate inputs
if images is None:
raise ValueError("Images input cannot be None")
if not isinstance(images, torch.Tensor):
raise ValueError("Images must be a torch tensor")
if len(images.shape) != 4 or images.shape[-1] != 3:
raise ValueError(f"Images tensor must have shape (B, H, W, 3), got {images.shape}")
if images.shape[0] == 0:
raise ValueError("Images tensor cannot be empty")
# Parse lora weights from string
weights_list = []
if lora_weights.strip():
try:
weights_list = [float(w.strip()) for w in lora_weights.split(',') if w.strip()]
except ValueError:
print(f"Warning: Invalid lora_weights format '{lora_weights}', using equal weights")
weights_list = []
# Process the images
result_path = process_images_to_lora(images, lora_name, batch_size, weights_list, normalize_saved_lora_strength_to, reduce_size_factor)
return (result_path,)
except Exception as e:
print(f"Z-Image - Images To LoRA Error: {e}")
import traceback
traceback.print_exc()
raise e
# ComfyUI node class for Image Selector
class ImageSelectorNodeAE:
"""
Image Selector Node - Interactive image selection from folder
This node allows users to browse and select images from a folder using an interactive
pygame-based UI. Selected images are automatically resized and batched into tensors
suitable for use with other ComfyUI nodes.
Features:
- Browse images in folders with thumbnail grid view
- Click to select/deselect images (maintains selection order)
- Scroll through large image collections
- Automatic resizing with aspect ratio preservation
- Batch output in ComfyUI IMAGE format
- Optional target dimensions for consistent sizing
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"folder_path": ("STRING", {
"default": "",
"tooltip": "Path to folder containing images to select from (or somewhere to start browsing)"
}),
},
"optional": {
"target_width": ("INT", {
"default": 0,
"min": 0,
"max": 4096,
"step": 1,
"tooltip": "Target width for resized images (0 = auto-calculate from largest image)"
}),
"target_height": ("INT", {
"default": 0,
"min": 0,
"max": 4096,
"step": 1,
"tooltip": "Target height for resized images (0 = auto-calculate from largest image)"
}),
"seed": ("INT", {
"default": 0,
"min": 0,
"max": 0xffffffffffffffff,
"step": 1,
"tooltip": "Change this value to force the node to run again with the same inputs"
}),
"padding_color": ("STRING", {
"default": "#000000",
"tooltip": "Hex color for image padding (e.g., #000000 for black, #FFFFFF for white)"
}),
}
}
RETURN_TYPES = ("IMAGE", any_typ, "INT")
RETURN_NAMES = ("images", "masks", "batch_count")
OUTPUT_TOOLTIPS = (
"Selected images as batched tensor in ComfyUI IMAGE format",
"Alpha masks for selected images (for images with transparency)",
"Number of images in the batch"
)
FUNCTION = "select_images"
CATEGORY = "ae-in-workflow"
def select_images(self, folder_path, target_width=0, target_height=0, seed=0, padding_color="#000000"):
"""
Launch interactive image selector UI
Args:
folder_path: Path to folder containing images
target_width: Target width for resizing (0 = auto)
target_height: Target height for resizing (0 = auto)
Returns:
tuple: (image_batch_tensor, mask_batch_tensor)
"""
try:
# Validate inputs before proceeding
if not folder_path or not isinstance(folder_path, str):
raise ValueError(f"folder_path must be a non-empty string, got {type(folder_path).__name__}: {folder_path}")
import os
if not os.path.exists(folder_path):
raise ValueError(f"folder_path does not exist: {folder_path}")
if not os.path.isdir(folder_path):
raise ValueError(f"folder_path is not a directory: {folder_path}")
if target_width is not None and target_width > 0:
if not isinstance(target_width, int):
raise ValueError(f"target_width must be an integer, got {type(target_width).__name__}: {target_width}")
if target_height is not None and target_height > 0:
if not isinstance(target_height, int):
raise ValueError(f"target_height must be an integer, got {type(target_height).__name__}: {target_height}")
if padding_color:
if not isinstance(padding_color, str):
raise ValueError(f"padding_color must be a string, got {type(padding_color).__name__}: {padding_color}")
# Basic hex color validation (#RRGGBB or #RGB)
import re
if not re.match(r'^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$', padding_color):
raise ValueError(f"padding_color must be a valid hex color (e.g., #FF0000), got: {padding_color}")
# Call the image selector function
# It returns: image_list, mask_list, image_batch, mask_batch
_, _, image_batch, mask_batch = image_selector(
folder_path,
target_width if target_width > 0 else None,
target_height if target_height > 0 else None,
padding_color
)
# Get batch count
batch_count = image_batch.shape[0] if image_batch.numel() > 0 else 0
# Return in ComfyUI format
return (image_batch, mask_batch, batch_count)
except Exception as e:
# Re-raise the exception so ComfyUI displays it properly to the user
raise e
# ComfyUI node class for Crop Image Batch to Mask Bounds
class CropImageBatchToMaskBoundsNodeAE:
"""
Crop Image Batch to Mask Bounds - Crop images to mask boundaries with intelligent resizing
This node crops images based on mask boundaries, with options for individual or combined cropping.
Useful for preparing masked objects for further processing while maintaining consistent dimensions.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE", {"tooltip": "Batch of images to crop (shape: B,H,W,C)"}),
"masks": ("MASK", {"tooltip": "Batch of masks defining crop boundaries (shape: B,H,W)"}),
},
"optional": {
"invert_mask": ("BOOLEAN", {
"default": False,
"tooltip": "Invert the mask before finding bounds"
}),
"mode": (["mask_for_each", "sum_of_masks"], {
"default": "mask_for_each",
"tooltip": "mask_for_each: crop each to individual mask bounds, then resize then pad to match combined bounds.\nsum_of_masks: crop all to combined mask bounds"
}),
"padding_color": ("STRING", {
"default": "#000000",
"tooltip": "Hex color for padding (e.g., #FF0000 for red)"
}),
"optional_padding": ("INT", {
"default": 0,
"min": 0,
"max": 512,
"step": 1,
"tooltip": "Additional padding pixels to add around cropped area on each side"
}),
"optional_new_width": ("INT", {
"default": 0,
"min": 0,
"max": 4096,
"step": 1,
"tooltip": "Force output width to this size (0 = use combined mask bounds)"
}),
"optional_new_height": ("INT", {
"default": 0,
"min": 0,
"max": 4096,
"step": 1,
"tooltip": "Force output height to this size (0 = use combined mask bounds)"
}),
"color_fill_mask": ("BOOLEAN", {
"default": True,
"tooltip": "Fill masked areas with padding color before processing"
}),
}
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("images", "masks")
OUTPUT_TOOLTIPS = (
"Cropped and resized images with consistent dimensions",
"Corresponding masks cropped, resized, and padded to match images"
)
FUNCTION = "crop_to_mask_bounds"
CATEGORY = "ae-in-workflow"
def crop_to_mask_bounds(self, images, masks, invert_mask=False, mode="mask_for_each", padding_color="#000000", optional_padding=0, color_fill_mask=True, optional_new_width=0, optional_new_height=0):
"""
Crop images to mask bounds with intelligent resizing
Args:
images: Tensor of images (B, H, W, C)
masks: Tensor of masks (B, H, W)
invert_mask: Whether to invert masks before cropping
mode: "mask_for_each" or "sum_of_masks"
padding_color: Hex color for padding
optional_padding: Extra padding around cropped area
color_fill_mask: Whether to fill mask areas with padding color
Returns:
tuple: (cropped_images, cropped_masks)
"""
# Validate tensor shapes and batch sizes
if not isinstance(images, torch.Tensor):
raise ValueError(f"images must be a torch.Tensor, got {type(images).__name__}")
if not isinstance(masks, torch.Tensor):
raise ValueError(f"masks must be a torch.Tensor, got {type(masks).__name__}")
if len(images.shape) != 4:
raise ValueError(f"images must be 4D (B, H, W, C), got shape {images.shape}")
if len(masks.shape) != 3:
raise ValueError(f"masks must be 3D (B, H, W), got shape {masks.shape}")
if images.shape[0] != masks.shape[0]:
raise ValueError(f"images and masks must have same batch size, got images: {images.shape[0]}, masks: {masks.shape[0]}")
if images.shape[1] != masks.shape[1] or images.shape[2] != masks.shape[2]:
raise ValueError(f"images and masks must have same spatial dimensions, got images: ({images.shape[1]}, {images.shape[2]}), masks: ({masks.shape[1]}, {masks.shape[2]})")
# Validate inputs
if mode not in ["mask_for_each", "sum_of_masks"]:
raise ValueError(f"mode must be 'mask_for_each' or 'sum_of_masks', got: {mode}")
if not isinstance(optional_padding, int) or optional_padding < 0:
raise ValueError(f"optional_padding must be a non-negative integer, got: {optional_padding}")
if not isinstance(optional_new_width, int) or optional_new_width < 0:
raise ValueError(f"optional_new_width must be a non-negative integer, got: {optional_new_width}")
if not isinstance(optional_new_height, int) or optional_new_height < 0:
raise ValueError(f"optional_new_height must be a non-negative integer, got: {optional_new_height}")
# Validate padding_color format
import re
if not isinstance(padding_color, str) or not re.match(r'^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$', padding_color):
raise ValueError(f"padding_color must be a valid hex color (e.g., #FF0000), got: {padding_color}")
# Convert hex color to RGB tuple (needed for pre-padding)
try:
padding_rgb = hex_to_rgb(padding_color)
except ValueError:
print(f"Invalid padding_color '{padding_color}', using black")
padding_rgb = (0, 0, 0)
# Pre-process: expand canvas to target dimensions if specified
if optional_new_width > 0 or optional_new_height > 0:
target_width = optional_new_width
target_height = optional_new_height
images, masks = self._expand_canvas(images, masks, padding_rgb, target_width, target_height)
try:
# padding_rgb already converted above
# Process based on mode - use target dimensions as forced bounds if specified
forced_bounds = (target_width, target_height) if 'target_width' in locals() and target_width > 0 else None
if mode == "mask_for_each":
return self._crop_mask_for_each(images, masks, invert_mask, padding_rgb, optional_padding, color_fill_mask, forced_bounds)
elif mode == "sum_of_masks":
return self._crop_sum_of_masks(images, masks, invert_mask, padding_rgb, optional_padding, color_fill_mask, forced_bounds)
else:
raise ValueError(f"Unknown mode: {mode}")
except Exception as e:
# Re-raise the exception so ComfyUI displays it properly to the user
raise e
def _crop_mask_for_each(self, images, masks, invert_mask, padding_rgb, optional_padding, color_fill_mask, forced_bounds):
"""Crop each image to its individual mask bounds, then resize all to match bounds"""
import torch
import numpy as np
batch_size = images.shape[0]
height, width = images.shape[1], images.shape[2]
cropped_images = []
cropped_masks = []
all_crop_heights = []
all_crop_widths = []
# First pass: crop each image individually
for i in range(batch_size):
img_tensor = images[i] # (H, W, C)
mask_tensor = masks[i] # (H, W)
# Convert tensors to numpy for processing
img_np = (img_tensor * 255).clamp(0, 255).byte().cpu().numpy()
mask_np = mask_tensor.cpu().numpy()
# Apply color_fill_mask preprocessing (fill opposite of what invert_mask uses)
if color_fill_mask:
if invert_mask:
# Fill the mask areas (foreground) with padding color
fill_mask = mask_np > 0.5
img_np[fill_mask] = padding_rgb
else:
# Fill the inverted areas (background) with padding color
inverted_mask = 1.0 - mask_np
fill_mask = inverted_mask > 0.5
img_np[fill_mask] = padding_rgb
# Use original mask for bounds finding (don't invert for bounds)
bounds_mask = mask_np
if invert_mask:
bounds_mask = 1.0 - mask_np
# Find mask bounds
mask_binary = bounds_mask > 0.5
if not mask_binary.any():
# Empty mask - use full image
min_y, max_y = 0, height
min_x, max_x = 0, width
else:
rows = np.any(mask_binary, axis=1)
cols = np.any(mask_binary, axis=0)
min_y, max_y = np.where(rows)[0][[0, -1]]
min_x, max_x = np.where(cols)[0][[0, -1]]
# Add optional padding
min_y = max(0, min_y - optional_padding)
max_y = min(height, max_y + optional_padding)
min_x = max(0, min_x - optional_padding)
max_x = min(width, max_x + optional_padding)
crop_height = max_y - min_y + 1
crop_width = max_x - min_x + 1
# Track dimensions
all_crop_heights.append(crop_height)
all_crop_widths.append(crop_width)
# Crop image and original mask (not the inverted one)
cropped_img = img_np[min_y:max_y, min_x:max_x]
cropped_mask = mask_np[min_y:max_y, min_x:max_x] # Always use original mask
cropped_images.append((cropped_img, crop_width, crop_height))
cropped_masks.append((cropped_mask, crop_width, crop_height))
# Determine final canvas dimensions
if forced_bounds:
# Use forced bounds
canvas_width, canvas_height = forced_bounds
else:
# Use combined bounds (existing behavior)
canvas_width = max(all_crop_widths) if all_crop_widths else 1
canvas_height = max(all_crop_heights) if all_crop_heights else 1
# Second pass: scale each cropped image UP to fit within canvas dimensions (maintaining aspect ratio), then pad
final_images = []
final_masks = []
for (cropped_img, orig_w, orig_h), (cropped_mask, _, _) in zip(cropped_images, cropped_masks):
# Scale UP to touch canvas bounds while maintaining aspect ratio
scale_w = canvas_width / orig_w
scale_h = canvas_height / orig_h
# Use the LARGER scale to ensure at least one dimension touches the edge
scale = max(scale_w, scale_h)
new_w = int(orig_w * scale)
new_h = int(orig_h * scale)
# Ensure we don't exceed canvas bounds
if new_w > canvas_width or new_h > canvas_height:
scale = min(canvas_width / orig_w, canvas_height / orig_h)
new_w = int(orig_w * scale)
new_h = int(orig_h * scale)
# Ensure at least 1 pixel
new_w = max(1, new_w)
new_h = max(1, new_h)
# Resize the cropped image and mask
from PIL import Image
pil_img = Image.fromarray(cropped_img)
pil_mask = Image.fromarray((cropped_mask * 255).astype(np.uint8), mode='L')
resized_img = pil_img.resize((new_w, new_h), Image.Resampling.LANCZOS)
resized_mask = pil_mask.resize((new_w, new_h), Image.Resampling.LANCZOS)
# Create canvas with target dimensions
canvas = np.full((canvas_height, canvas_width, 3), padding_rgb, dtype=np.uint8)
mask_canvas = np.zeros((canvas_height, canvas_width), dtype=np.float32)
# Center the resized content
y_offset = (canvas_height - new_h) // 2
x_offset = (canvas_width - new_w) // 2
# Paste resized content
canvas[y_offset:y_offset+new_h, x_offset:x_offset+new_w] = np.array(resized_img)
mask_canvas[y_offset:y_offset+new_h, x_offset:x_offset+new_w] = np.array(resized_mask).astype(np.float32) / 255.0
# Convert back to tensors
img_tensor = torch.from_numpy(canvas).float() / 255.0
mask_tensor = torch.from_numpy(mask_canvas)
final_images.append(img_tensor)
final_masks.append(mask_tensor)
# Batch tensors
image_batch = torch.stack(final_images, dim=0) # (B, H, W, C)
mask_batch = torch.stack(final_masks, dim=0) # (B, H, W)
return image_batch, mask_batch
def _crop_sum_of_masks(self, images, masks, invert_mask, padding_rgb, optional_padding, color_fill_mask, forced_bounds):
"""Crop all images to the combined bounds of all masks"""
import torch
import numpy as np
batch_size = images.shape[0]
height, width = images.shape[1], images.shape[2]
# Apply color_fill_mask preprocessing to all images
processed_images = []
processed_masks = []
for i in range(batch_size):
img_tensor = images[i]
mask_tensor = masks[i]
img_np = (img_tensor * 255).clamp(0, 255).byte().cpu().numpy()
mask_np = mask_tensor.cpu().numpy()
# Apply color_fill_mask preprocessing (fill opposite of what invert_mask uses)
if color_fill_mask:
if invert_mask:
# Fill the mask areas (foreground) with padding color
fill_mask = mask_np > 0.5
img_np[fill_mask] = padding_rgb
else:
# Fill the inverted areas (background) with padding color
inverted_mask = 1.0 - mask_np
fill_mask = inverted_mask > 0.5
img_np[fill_mask] = padding_rgb
processed_images.append(img_np)
processed_masks.append(mask_np)
# Find combined bounds across all masks
global_min_y = height
global_max_y = 0
global_min_x = width
global_max_x = 0
for i in range(batch_size):
mask_np = processed_masks[i]
bounds_mask = mask_np
if invert_mask:
bounds_mask = 1.0 - mask_np
mask_binary = bounds_mask > 0.5
if mask_binary.any():
rows = np.any(mask_binary, axis=1)
cols = np.any(mask_binary, axis=0)
min_y, max_y = np.where(rows)[0][[0, -1]]
min_x, max_x = np.where(cols)[0][[0, -1]]
global_min_y = min(global_min_y, min_y)
global_max_y = max(global_max_y, max_y)
global_min_x = min(global_min_x, min_x)
global_max_x = max(global_max_x, max_x)
# If no valid masks found, use full bounds
if global_min_y >= global_max_y:
global_min_y, global_max_y = 0, height
global_min_x, global_max_x = 0, width
# Add optional padding
global_min_y = max(0, global_min_y - optional_padding)
global_max_y = min(height, global_max_y + optional_padding)
global_min_x = max(0, global_min_x - optional_padding)
global_max_x = min(width, global_max_x + optional_padding)
# Use forced bounds or calculated bounds
if forced_bounds:
# Use full image bounds for cropping
global_min_y, global_max_y = 0, height
global_min_x, global_max_x = 0, width
# Crop all processed images and original masks to these bounds
cropped_images = []
cropped_masks = []
for i in range(batch_size):
img_np = processed_images[i]
mask_np = processed_masks[i] # Always use original mask for output
# Crop
cropped_img = img_np[global_min_y:global_max_y, global_min_x:global_max_x]
cropped_mask = mask_np[global_min_y:global_max_y, global_min_x:global_max_x]
# Convert back to tensors
img_tensor = torch.from_numpy(cropped_img).float() / 255.0
mask_tensor = torch.from_numpy(cropped_mask)
cropped_images.append(img_tensor)
cropped_masks.append(mask_tensor)
# Batch tensors
image_batch = torch.stack(cropped_images, dim=0) # (B, H, W, C)
mask_batch = torch.stack(cropped_masks, dim=0) # (B, H, W)
return image_batch, mask_batch
def _expand_canvas(self, images, masks, padding_rgb, target_width, target_height):
"""Expand canvas to at least target dimensions by padding"""
import torch
import numpy as np
batch_size = images.shape[0]
current_height, current_width = images.shape[1], images.shape[2]
# Expand dimensions if needed
expand_width = max(target_width, current_width)
expand_height = max(target_height, current_height)
# If no expansion needed, return as-is
if expand_width == current_width and expand_height == current_height:
return images, masks
padded_images = []
padded_masks = []
for i in range(batch_size):
img_tensor = images[i]
img_np = (img_tensor * 255).clamp(0, 255).byte().cpu().numpy()
# Create expanded canvas
expanded_img = np.full((expand_height, expand_width, 3), padding_rgb, dtype=np.uint8)
# Copy original content
expanded_img[:current_height, :current_width] = img_np
# Same for mask (pad with 0)
mask_tensor = masks[i]
mask_np = mask_tensor.cpu().numpy()
expanded_mask = np.zeros((expand_height, expand_width), dtype=np.float32)
expanded_mask[:current_height, :current_width] = mask_np
# Convert back to tensors
img_tensor = torch.from_numpy(expanded_img).float() / 255.0
mask_tensor = torch.from_numpy(expanded_mask)
padded_images.append(img_tensor)
padded_masks.append(mask_tensor)
# Batch tensors
image_batch = torch.stack(padded_images, dim=0)
mask_batch = torch.stack(padded_masks, dim=0)
return image_batch, mask_batch
# ComfyUI node class for Float List Interpolation
class InterpolateFloatListNodeAE:
"""
Interpolate Float List - Process and interpolate lists of float values
This node takes a comma-separated list of floats and either interpolates them
across a specified count or trims/pads the list to match the count.
Useful for creating smooth transitions between values or ensuring consistent
list lengths for batch processing.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"floats": ("STRING", {
"default": "3,2.5,2,1,0.5",
"multiline": True,
"tooltip": "Comma-separated list of float values (can use newlines, whitespace will be removed)"
}),
},
"optional": {
"count": ("INT", {
"default": 0,
"min": 0,
"max": 1000,
"step": 1,
"tooltip": "Target count for interpolation/trimming (0 = return original list)"
}),
"interpolate_across_count": ("BOOLEAN", {
"default": True,
"label_on": "Yes",
"label_off": "No pad right with 1's",
"tooltip": "If Yes: interpolate values across count. If No: trim/pad with 1s to match count"
}),
}
}
RETURN_TYPES = (any_typ, "STRING")
RETURN_NAMES = ("float_list", "csv_string")
OUTPUT_TOOLTIPS = (
"List of interpolated/processed float values",
"Comma-separated string of the float values"
)
FUNCTION = "interpolate_floats"
CATEGORY = "ae-in-workflow"
def interpolate_floats(self, floats, count=0, interpolate_across_count=True):
"""
Process and interpolate float list
Args:
floats: Comma-separated string of float values
count: Target count for processing
interpolate_across_count: Whether to interpolate or trim/pad
Returns:
tuple: (list of processed float values, comma-separated string of values)
"""
try:
# Process the input string
# Replace newlines with commas
processed = floats.replace('\n', ',')
# Remove all whitespace
processed = ''.join(processed.split())
# Parse into float list, filtering out empty strings
values = [float(n) for n in processed.split(",") if n.strip() != ""]
# If count <= 0, return original list
if count <= 0:
rounded_values = [round(v, 6) for v in values]
csv_string = ','.join(str(v) for v in rounded_values)
return (rounded_values, csv_string)
if interpolate_across_count:
# Interpolate across count items
if len(values) <= 1:
# Not enough values to interpolate, return repeated first value
rounded_values = [round(values[0], 6)] * count if values else [1.0] * count
csv_string = ','.join(str(v) for v in rounded_values)
return (rounded_values, csv_string)
else:
# Linear interpolation between values
result = []
for i in range(count):
# Calculate position in original array
pos = (i / max(1, count - 1)) * (len(values) - 1)
lower_idx = int(pos)
upper_idx = min(lower_idx + 1, len(values) - 1)
fraction = pos - lower_idx
# Interpolate between lower and upper values
interpolated_value = values[lower_idx] + (values[upper_idx] - values[lower_idx]) * fraction
result.append(round(interpolated_value, 6))
csv_string = ','.join(str(v) for v in result)
return (result, csv_string)
else:
# Trim/pad with 1s to match count
if len(values) >= count:
# Trim to count
rounded_values = [round(v, 6) for v in values[:count]]
csv_string = ','.join(str(v) for v in rounded_values)
return (rounded_values, csv_string)
else:
# Pad with 1s
result = [round(v, 6) for v in values] # Copy the list with rounding
result.extend([1.0] * (count - len(values)))
csv_string = ','.join(str(v) for v in result)
return (result, csv_string)
except Exception as e:
print(f"Interpolate Float List Error: {e}")
import traceback
traceback.print_exc()
# Return empty list and empty string on error
return ([], "")
# Node registration
NODE_CLASS_MAPPINGS = {
"PoseEditorAE": PoseEditorNodeAE,
"ZImageImagesToLoRAAE": ZImageImagesToLoRANodeAE,
"ImageSelectorAE": ImageSelectorNodeAE,
"CropImageBatchToMaskBoundsAE": CropImageBatchToMaskBoundsNodeAE,
"InterpolateFloatListAE": InterpolateFloatListNodeAE,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PoseEditorAE": "Interactive Pose Editor (ae)",
"ZImageImagesToLoRAAE": "Z-Image Images To LoRA (ae)",
"ImageSelectorAE": "Image Selector (ae)",
"CropImageBatchToMaskBoundsAE": "Crop Image Batch to Mask Bounds (ae)",
"InterpolateFloatListAE": "Interpolate Float List (ae)",
}
# Web directory for any static assets (if needed in future)
WEB_DIRECTORY = "./web"