Files

4055 lines
167 KiB
Python

import numpy as np
from PIL import Image, ImageSequence, ImageOps
from PIL.PngImagePlugin import PngInfo # Added import
from sklearn.cluster import KMeans
from skimage import color
import torch
import comfy.utils
from concurrent.futures import ThreadPoolExecutor
import cv2
from typing import Dict, Tuple
import numba
import os
import random
import folder_paths
from nodes import PreviewImage
import io
import base64
import colorsys
import json
import math # Added for ceil/floor
import struct
import hashlib
import node_helpers
# Numba optimized functions
@numba.jit(nopython=True)
def _find_closest_color_numba(pixel_rgb, palette_rgb):
"""Numba-optimized function to find the closest color index in a palette."""
diff = palette_rgb - pixel_rgb # Broadcasting
distances_sq = np.sum(diff * diff, axis=1)
return np.argmin(distances_sq)
@numba.jit(nopython=True)
def _apply_floyd_steinberg_dithering_numba(img_float, palette, palette_rgb, dithering_amount, has_alpha, transparent_idx):
"""Numba-optimized Floyd-Steinberg dithering."""
height, width, channels = img_float.shape
result = img_float.copy() # Work on a copy
# Define error weights (constants are fine in nopython mode)
err_weights_dx = np.array([1, -1, 0, 1], dtype=np.int32)
err_weights_dy = np.array([0, 1, 1, 1], dtype=np.int32)
err_weights_val = np.array([7/16, 3/16, 5/16, 1/16], dtype=np.float32) * dithering_amount # Apply amount here
alpha_mask = np.empty((height, width), dtype=numba.boolean)
if has_alpha:
# Precompute alpha mask
alpha_mask = result[:, :, 3] < 1.0
# Set transparent pixels directly
# transparent_idx != -1 implies palette is RGBA and has a defined transparent entry
if transparent_idx != -1:
transparent_palette_entry = palette[transparent_idx].astype(np.float32)
for y in numba.prange(height): # Use prange for potential parallelization
for x in range(width):
if alpha_mask[y, x]:
# Since transparent_idx != -1, palette[transparent_idx] is RGBA.
# result is also RGBA because has_alpha is true.
result[y, x] = transparent_palette_entry
else:
# Create a dummy mask if no alpha
alpha_mask = np.zeros((height, width), dtype=numba.boolean)
for y in range(height):
for x in range(width):
# Skip transparent pixels already set
if has_alpha and alpha_mask[y, x]:
continue
old_pixel = result[y, x].copy() # Copy necessary for error calc
old_pixel_rgb = old_pixel[:3]
# Find closest color in the RGB palette
closest_idx = _find_closest_color_numba(old_pixel_rgb, palette_rgb)
# Get the RGB values from palette_rgb (which is always N,3)
quantized_rgb = palette_rgb[closest_idx]
result[y, x, :3] = quantized_rgb # Assign new RGB
# Handle alpha channel for the current pixel in result
if has_alpha: # If original image has alpha channel
if palette.shape[1] == 4: # If palette also has alpha channel
result[y, x, 3] = palette[closest_idx, 3] # Use alpha from palette
# Else (image has alpha, but palette is RGB):
# alpha of result[y,x,3] retains its original value from img_float (result is a copy).
# Else (original image is RGB): result has only 3 channels, nothing to do for alpha.
# Calculate error only for RGB channels
error_rgb = old_pixel_rgb - quantized_rgb # error_rgb is a 3-element array
# Distribute the RGB error to RGB channels of neighbors
for i in range(len(err_weights_dx)):
nx, ny = x + err_weights_dx[i], y + err_weights_dy[i]
# Check bounds
if 0 <= nx < width and 0 <= ny < height:
# Don't distribute error to transparent pixels (those pre-filled or originally transparent)
if not (has_alpha and alpha_mask[ny, nx]):
result[ny, nx, :3] += error_rgb * err_weights_val[i]
# Clip values at the end
# Numba doesn't directly support np.clip with min/max arrays easily across dimensions
# So we clip per channel (channels is from original img_float)
for i in range(channels):
result[:, :, i] = np.maximum(0.0, np.minimum(255.0, result[:, :, i]))
return result.astype(np.uint8)
@numba.jit(nopython=True)
def _color_distance_numba(color1, color2, max_channels):
"""Numba-optimized Euclidean distance between colors (up to max_channels)."""
dist_sq = 0.0
for i in range(max_channels):
diff = color1[i] - color2[i]
dist_sq += diff * diff
return np.sqrt(dist_sq)
@numba.jit(nopython=True)
def _flood_fill_core_numba(img, start_x, start_y, bg_color, max_color_diff, max_channels):
"""Numba-optimized core flood fill logic using a list as a queue."""
h, w = img.shape[:2]
# Prepare mask: 0=unprocessed, 1=background (filled), 2=processed but not filled
mask = np.zeros((h, w), dtype=np.uint8)
# Use a standard Python list for the queue - Numba supports simple list operations
queue = [(start_x, start_y)] # Use list of tuples
mask[start_y, start_x] = 1 # Mark start point as background
# Define 4-connected neighbors (constants are fine)
neighbors_dx = np.array([-1, 1, 0, 0], dtype=np.int32)
neighbors_dy = np.array([0, 0, -1, 1], dtype=np.int32)
processed_count = 0 # Keep track of queue additions to avoid excessive growth
max_queue_size = h * w # Theoretical max
queue_head = 0
while queue_head < len(queue):
x, y = queue[queue_head]
queue_head += 1 # Dequeue by advancing head index (more efficient than pop(0))
# No need to read current_color again, we know it matches bg_color
for i in range(len(neighbors_dx)):
nx, ny = x + neighbors_dx[i], y + neighbors_dy[i]
# Check bounds
if 0 <= nx < w and 0 <= ny < h:
# Check if already processed
if mask[ny, nx] == 0:
neighbor_color = img[ny, nx]
distance = _color_distance_numba(neighbor_color, bg_color, max_channels)
if distance <= max_color_diff:
mask[ny, nx] = 1 # Mark as background
queue.append((nx, ny)) # Enqueue
else:
mask[ny, nx] = 2 # Mark as processed but not background
# Return only the background mask (where mask == 1)
return (mask == 1).astype(np.uint8)
class SmartImagePaletteConvert:
ALGORITHM_HYBRID = "Hybrid Lab/RGB K-Means"
ALGORITHM_PERCEPTUAL = "Perceptual (Lab)"
ALGORITHM_KMEANS_RGB = "K-Means (RGB)"
ALGORITHM_SELECTIVE = "Selective"
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"num_colors": ("INT", {"default": 8, "min": 2, "max": 256, "step": 1}),
"dithering_amount": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.05, "display": "slider"}),
"algorithm": ([
s.ALGORITHM_HYBRID,
s.ALGORITHM_PERCEPTUAL,
s.ALGORITHM_KMEANS_RGB,
s.ALGORITHM_SELECTIVE,
], {"default": s.ALGORITHM_HYBRID}),
},
"optional": {
"reference_image": ("IMAGE",),
"additional_colors": ("IMAGE",),
}
}
RETURN_TYPES = ("IMAGE", "IMAGE")
RETURN_NAMES = ("image", "palette")
FUNCTION = "convert_image"
CATEGORY = "SmartImageTools"
@staticmethod
def _sort_palette(palette):
"""Sorts a palette primarily by hue, then luminance, then saturation.
Assigns special hue values for gray/transparent colors.
"""
if palette.shape[0] <= 1:
return palette
has_alpha = palette.shape[1] == 4
color_data = [] # List to store (sort_key, original_color)
for color_val in palette:
is_transparent = has_alpha and color_val[3] == 0
if is_transparent:
hue_value = -2
l = 0.0
s = 0.0
else:
rgb = np.clip(color_val[:3], 0.0, 255.0) / 255.0
h, l, s = colorsys.rgb_to_hls(rgb[0], rgb[1], rgb[2])
if s < 0.05 or l < 0.01 or l > 0.99: # Achromatic threshold (low saturation or near black/white)
hue_value = -1
else:
hue_value = h
if not is_transparent and (s < 0.05 or l < 0.01 or l > 0.99):
hue_value = -1
# Sort key: Hue -> Luminance -> Saturation
sort_key = (hue_value, l, s)
color_data.append((sort_key, color_val))
# Sort based on the calculated key
color_data.sort(key=lambda x: x[0])
# Extract sorted colors
sorted_palette_list = [item[1] for item in color_data]
return np.array(sorted_palette_list, dtype=palette.dtype)
@staticmethod
def find_closest_color_vectorized(pixels, palette):
"""Find the closest color in the palette for each pixel (vectorized)."""
# Reshape pixels to 2D if they aren't already
original_shape = pixels.shape
if len(original_shape) > 2:
pixels_reshaped = pixels.reshape(-1, original_shape[-1])
else:
pixels_reshaped = pixels
# Compute distances between each pixel and each palette color
# Uses broadcasting for efficient computation
diff = pixels_reshaped[:, np.newaxis, :3] - palette[np.newaxis, :, :3]
distances = np.sum(diff**2, axis=2)
indices = np.argmin(distances, axis=1)
# Get the closest colors
closest_colors = palette[indices]
return closest_colors, indices
@staticmethod
def _empty_palette(img):
channel_count = img.shape[2] if img.ndim == 3 else 3
return np.zeros((1, channel_count), dtype=np.float32)
@staticmethod
def _compute_palette_seed(pixel_data):
if pixel_data.size == 0:
return 42
clipped = np.clip(pixel_data, 0, 255).astype(np.uint8, copy=False)
digest = hashlib.sha256(clipped.tobytes()).digest()
return int.from_bytes(digest[:8], "little") & 0x7FFFFFFF
@staticmethod
def _resize_for_palette(img, max_dim=256):
h, w = img.shape[:2]
if max(h, w) > max_dim:
scale = max_dim / max(h, w)
new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
return cv2.resize(img, new_size, interpolation=cv2.INTER_AREA)
return img
@staticmethod
def _extract_opaque_rgb(img):
img_flat = img.reshape(-1, img.shape[2]).astype(np.float32)
if img.shape[2] == 4:
return img_flat[img_flat[:, 3] > 0][:, :3]
return img_flat[:, :3]
@staticmethod
def _build_palette_from_centers(img, centers_rgb_float, has_transparency=True):
centers_rgb_float = np.asarray(centers_rgb_float, dtype=np.float32)
if centers_rgb_float.ndim == 1:
centers_rgb_float = centers_rgb_float.reshape(1, -1)
if has_transparency:
transparent_rgb = np.array([-99999.0, -99999.0, -99999.0], dtype=np.float32)
if img.shape[2] == 4:
palette = np.zeros((len(centers_rgb_float) + 1, 4), dtype=np.float32)
if len(centers_rgb_float) > 0:
palette[:-1, :3] = centers_rgb_float
palette[:-1, 3] = 255.0
palette[-1, :3] = transparent_rgb
palette[-1, 3] = 0.0
else:
palette = np.zeros((len(centers_rgb_float) + 1, 3), dtype=np.float32)
if len(centers_rgb_float) > 0:
palette[:-1] = centers_rgb_float
palette[-1] = transparent_rgb
else:
if img.shape[2] == 4:
palette = np.zeros((len(centers_rgb_float), 4), dtype=np.float32)
if len(centers_rgb_float) > 0:
palette[:, :3] = centers_rgb_float
palette[:, 3] = 255.0
else:
palette = centers_rgb_float.astype(np.float32, copy=False)
return SmartImagePaletteConvert._sort_palette(palette)
@staticmethod
def _compute_weighted_unique_colors(rgb_pixels):
rgb_uint8 = np.clip(rgb_pixels, 0, 255).astype(np.uint8, copy=False)
unique_rgb, counts = np.unique(rgb_uint8, axis=0, return_counts=True)
return unique_rgb.astype(np.float32), counts.astype(np.float64)
@staticmethod
def _weighted_kmeans(points, weights, num_clusters, seed, max_iterations=50, convergence_threshold=0.1):
if len(points) == 0:
return np.zeros((1, points.shape[1] if points.ndim == 2 else 3), dtype=np.float32)
points = np.asarray(points, dtype=np.float32)
weights = np.asarray(weights, dtype=np.float64)
k = max(1, min(int(num_clusters), len(points)))
rng = np.random.default_rng(seed)
centers = np.zeros((k, points.shape[1]), dtype=np.float32)
cumulative_weights = np.cumsum(weights)
first_pick = rng.random() * cumulative_weights[-1]
first_idx = min(np.searchsorted(cumulative_weights, first_pick, side="right"), len(points) - 1)
centers[0] = points[first_idx]
min_dist_sq = np.full(len(points), np.inf, dtype=np.float64)
for center_idx in range(1, k):
diff = points - centers[center_idx - 1]
dist_sq = np.sum(diff * diff, axis=1)
min_dist_sq = np.minimum(min_dist_sq, dist_sq)
weighted_dist_sq = min_dist_sq * weights
total = float(np.sum(weighted_dist_sq))
if total <= 0.0:
centers[center_idx:] = points[:k - center_idx]
break
pick = rng.random() * total
next_idx = min(np.searchsorted(np.cumsum(weighted_dist_sq), pick, side="right"), len(points) - 1)
centers[center_idx] = points[next_idx]
for _ in range(max_iterations):
diff = points[:, np.newaxis, :] - centers[np.newaxis, :, :]
distances_sq = np.sum(diff * diff, axis=2)
assignments = np.argmin(distances_sq, axis=1)
new_centers = centers.copy()
max_shift = 0.0
for cluster_idx in range(k):
mask = assignments == cluster_idx
if np.any(mask):
cluster_weights = weights[mask]
weighted_sum = np.sum(points[mask] * cluster_weights[:, np.newaxis], axis=0)
updated_center = weighted_sum / np.sum(cluster_weights)
shift = float(np.sum((centers[cluster_idx] - updated_center) ** 2))
max_shift = max(max_shift, shift)
new_centers[cluster_idx] = updated_center.astype(np.float32)
else:
new_centers[cluster_idx] = points[rng.integers(0, len(points))]
centers = new_centers
if max_shift < convergence_threshold:
break
return centers
@staticmethod
def _compute_saturation(rgb_pixels):
rgb_norm = np.clip(rgb_pixels, 0.0, 255.0) / 255.0
max_c = np.max(rgb_norm, axis=1)
min_c = np.min(rgb_norm, axis=1)
delta = max_c - min_c
lightness = (max_c + min_c) / 2.0
saturation = np.zeros_like(max_c, dtype=np.float32)
valid = (max_c != 0.0) & (delta != 0.0)
low_mask = valid & (lightness <= 0.5)
saturation[low_mask] = delta[low_mask] / np.maximum(max_c[low_mask] + min_c[low_mask], 1e-8)
high_mask = valid & (lightness > 0.5)
saturation[high_mask] = delta[high_mask] / np.maximum(2.0 - max_c[high_mask] - min_c[high_mask], 1e-8)
return saturation.astype(np.float32)
@staticmethod
def _compute_edge_strengths(img_rgb):
rgb_norm = np.clip(img_rgb.astype(np.float32), 0.0, 255.0) / 255.0
luminance = 0.299 * rgb_norm[:, :, 0] + 0.587 * rgb_norm[:, :, 1] + 0.114 * rgb_norm[:, :, 2]
gx = cv2.Sobel(luminance, cv2.CV_32F, 1, 0, ksize=3)
gy = cv2.Sobel(luminance, cv2.CV_32F, 0, 1, ksize=3)
edge_strength = np.sqrt(gx * gx + gy * gy)
max_edge = float(np.max(edge_strength))
if max_edge > 0.0:
edge_strength /= max_edge
return edge_strength.astype(np.float32)
@staticmethod
def _make_selective_box(pixel_indices, rgb_pixels, weights, saturations):
box_rgb = rgb_pixels[pixel_indices]
min_rgb = np.min(box_rgb, axis=0)
max_rgb = np.max(box_rgb, axis=0)
ranges = max_rgb - min_rgb
total_weight = float(np.sum(weights[pixel_indices]))
if total_weight > 0.0:
avg_saturation = float(np.sum(weights[pixel_indices] * saturations[pixel_indices]) / total_weight)
weighted_color = np.sum(box_rgb * weights[pixel_indices, np.newaxis], axis=0) / total_weight
else:
avg_saturation = 0.0
weighted_color = np.mean(box_rgb, axis=0)
return {
"indices": pixel_indices,
"min_rgb": min_rgb,
"max_rgb": max_rgb,
"ranges": ranges,
"largest_channel": int(np.argmax(ranges)),
"largest_range": float(np.max(ranges)),
"total_weight": total_weight,
"avg_saturation": avg_saturation,
"weighted_color": weighted_color.astype(np.float32),
}
def _generate_palette_hybrid(self, img, num_colors, has_transparency=True):
img_small = self._resize_for_palette(img)
rgb = self._extract_opaque_rgb(img_small)
if len(rgb) == 0:
return self._empty_palette(img)
max_pixels = 10000
if len(rgb) > max_pixels:
seed = self._compute_palette_seed(rgb[:max_pixels])
rng = np.random.default_rng(seed)
indices = rng.choice(len(rgb), max_pixels, replace=False)
rgb_subset = rgb[indices]
else:
rgb_subset = rgb
colors_to_extract = num_colors - 1 if has_transparency else num_colors
colors_to_extract = max(1, min(colors_to_extract, len(rgb_subset)))
rgb_normalized = np.clip(rgb_subset, 0.0, 255.0) / 255.0
lab = color.rgb2lab(rgb_normalized.reshape(-1, 1, 3)).reshape(-1, 3)
kmeans = KMeans(n_clusters=colors_to_extract, random_state=42, n_init=10)
kmeans.fit(lab)
centers_lab = kmeans.cluster_centers_
centers_rgb = color.lab2rgb(centers_lab.reshape(-1, 1, 3)).reshape(-1, 3)
centers_rgb_float = np.clip(centers_rgb * 255.0, 0.0, 255.0).astype(np.float32)
return self._build_palette_from_centers(img, centers_rgb_float, has_transparency=has_transparency)
def _generate_palette_perceptual(self, img, num_colors, has_transparency=True):
img_small = self._resize_for_palette(img)
rgb = self._extract_opaque_rgb(img_small)
if len(rgb) == 0:
return self._empty_palette(img)
unique_rgb, counts = self._compute_weighted_unique_colors(rgb)
colors_to_extract = num_colors - 1 if has_transparency else num_colors
colors_to_extract = max(1, min(colors_to_extract, len(unique_rgb)))
rgb_normalized = np.clip(unique_rgb, 0.0, 255.0) / 255.0
lab_points = color.rgb2lab(rgb_normalized.reshape(-1, 1, 3)).reshape(-1, 3).astype(np.float32)
seed = self._compute_palette_seed(unique_rgb)
centers_lab = self._weighted_kmeans(
lab_points,
counts,
colors_to_extract,
seed,
max_iterations=50,
convergence_threshold=0.01,
)
centers_rgb = color.lab2rgb(centers_lab.reshape(-1, 1, 3)).reshape(-1, 3)
centers_rgb_float = np.clip(np.round(centers_rgb * 255.0), 0.0, 255.0).astype(np.float32)
return self._build_palette_from_centers(img, centers_rgb_float, has_transparency=has_transparency)
def _generate_palette_kmeans_rgb(self, img, num_colors, has_transparency=True):
img_small = self._resize_for_palette(img)
rgb = self._extract_opaque_rgb(img_small)
if len(rgb) == 0:
return self._empty_palette(img)
unique_rgb, counts = self._compute_weighted_unique_colors(rgb)
colors_to_extract = num_colors - 1 if has_transparency else num_colors
colors_to_extract = max(1, min(colors_to_extract, len(unique_rgb)))
seed = self._compute_palette_seed(unique_rgb)
centers_rgb_float = self._weighted_kmeans(
unique_rgb,
counts,
colors_to_extract,
seed,
max_iterations=50,
convergence_threshold=0.1,
)
centers_rgb_float = np.clip(np.round(centers_rgb_float), 0.0, 255.0).astype(np.float32)
return self._build_palette_from_centers(img, centers_rgb_float, has_transparency=has_transparency)
def _generate_palette_selective(self, img, num_colors, has_transparency=True):
img_small = self._resize_for_palette(img)
rgb_image = img_small[:, :, :3].astype(np.float32)
rgb_flat = rgb_image.reshape(-1, 3)
edge_strengths = self._compute_edge_strengths(rgb_image).reshape(-1)
if img_small.shape[2] == 4:
alpha_flat = img_small[:, :, 3].reshape(-1)
opaque_mask = alpha_flat > 0
rgb_flat = rgb_flat[opaque_mask]
edge_strengths = edge_strengths[opaque_mask]
if len(rgb_flat) == 0:
return self._empty_palette(img)
saturations = self._compute_saturation(rgb_flat)
weights = 1.0 + 0.5 * saturations + 0.5 * edge_strengths.astype(np.float32)
colors_to_extract = num_colors - 1 if has_transparency else num_colors
colors_to_extract = max(1, min(colors_to_extract, len(rgb_flat)))
boxes = [
self._make_selective_box(
np.arange(len(rgb_flat), dtype=np.int32),
rgb_flat,
weights,
saturations,
)
]
while len(boxes) < colors_to_extract:
best_box_idx = -1
best_score = -1.0
for idx, box in enumerate(boxes):
if len(box["indices"]) <= 1 or box["largest_range"] <= 0.0:
continue
score = box["largest_range"] * box["total_weight"] * (0.5 + 0.5 * box["avg_saturation"])
if score > best_score:
best_score = score
best_box_idx = idx
if best_box_idx < 0:
break
box_to_split = boxes[best_box_idx]
channel = box_to_split["largest_channel"]
sorted_order = np.argsort(rgb_flat[box_to_split["indices"], channel], kind="mergesort")
sorted_indices = box_to_split["indices"][sorted_order]
cumulative_weights = np.cumsum(weights[sorted_indices])
split_pos = int(np.searchsorted(cumulative_weights, box_to_split["total_weight"] / 2.0, side="left"))
if split_pos <= 0:
split_pos = 1
if split_pos >= len(sorted_indices):
split_pos = len(sorted_indices) - 1
box1 = self._make_selective_box(sorted_indices[:split_pos], rgb_flat, weights, saturations)
box2 = self._make_selective_box(sorted_indices[split_pos:], rgb_flat, weights, saturations)
boxes[best_box_idx] = box1
boxes.append(box2)
centers_rgb_float = np.array([box["weighted_color"] for box in boxes], dtype=np.float32)
centers_rgb_float = np.clip(np.round(centers_rgb_float), 0.0, 255.0).astype(np.float32)
return self._build_palette_from_centers(img, centers_rgb_float, has_transparency=has_transparency)
def extract_palette_from_reference(self, reference_img, num_colors=None, has_transparency=True):
"""Extract palette from a reference image."""
# Extract exact palette from reference image
h, w = reference_img.shape[:2]
has_alpha = reference_img.shape[2] == 4
# Reshape the image to get all pixels
img_flat = reference_img.reshape(-1, reference_img.shape[2])
# For large images, sample a subset of pixels to avoid memory issues
max_pixels = 100000
if len(img_flat) > max_pixels:
indices = np.random.choice(len(img_flat), max_pixels, replace=False)
img_flat = img_flat[indices]
# Handle alpha channel if present
if has_alpha:
# Separate transparent from non-transparent pixels
mask = img_flat[:, 3] > 0
rgb_opaque = img_flat[mask]
# Get unique RGB colors (ignore alpha variations for same RGB)
# Work with float representation internally for potential negative values
unique_colors_float = np.unique(rgb_opaque[:, :3], axis=0).astype(np.float32)
# Create final palette with alpha channel using float32
palette = np.zeros((len(unique_colors_float) + 1, 4), dtype=np.float32)
palette[:len(unique_colors_float), :3] = unique_colors_float
palette[:len(unique_colors_float), 3] = 255.0 # Full opacity
# Use large negative RGB for transparent color temporarily (this is needed to cancel the transparent color from picking it while searching for the closest color)
palette[-1] = [-500.0, -500.0, -500.0, 0.0]
else:
# No alpha channel, just get unique RGB colors
unique_colors = np.unique(img_flat, axis=0)
# Convert to float32 for consistency
palette = unique_colors.astype(np.float32)
# Sorting works fine with floats
sorted_palette_float = self._sort_palette(palette)
# Return as uint8 after sorting and potential modification
return np.clip(sorted_palette_float, 0, 255).astype(np.uint8)
def merge_palettes(self, palette1, palette2):
"""Merge two palettes while avoiding duplicates."""
# Ensure both palettes are float32 for processing
palette1_float = palette1.astype(np.float32)
palette2_float = palette2.astype(np.float32)
# Determine if we need to handle alpha channels
has_alpha1 = palette1_float.shape[1] == 4
has_alpha2 = palette2_float.shape[1] == 4
if has_alpha1 and has_alpha2:
# Both have alpha - merge properly
# Combine all colors
combined = np.vstack([palette1_float, palette2_float])
# Find unique colors based on RGB values (ignore alpha for uniqueness)
rgb_colors = combined[:, :3]
# Create a set to track unique RGB combinations
unique_indices = []
seen_rgb = set()
for i, rgb in enumerate(rgb_colors):
rgb_tuple = tuple(rgb)
if rgb_tuple not in seen_rgb:
seen_rgb.add(rgb_tuple)
unique_indices.append(i)
# Extract unique colors
unique_palette = combined[unique_indices]
elif has_alpha1 or has_alpha2:
# One has alpha, the other doesn't - convert both to RGBA
if not has_alpha1:
# Add alpha channel to palette1
alpha_col = np.full((palette1_float.shape[0], 1), 255.0, dtype=np.float32)
palette1_float = np.hstack([palette1_float, alpha_col])
if not has_alpha2:
# Add alpha channel to palette2
alpha_col = np.full((palette2_float.shape[0], 1), 255.0, dtype=np.float32)
palette2_float = np.hstack([palette2_float, alpha_col])
# Now both have alpha, use the same logic as above
combined = np.vstack([palette1_float, palette2_float])
rgb_colors = combined[:, :3]
unique_indices = []
seen_rgb = set()
for i, rgb in enumerate(rgb_colors):
rgb_tuple = tuple(rgb)
if rgb_tuple not in seen_rgb:
seen_rgb.add(rgb_tuple)
unique_indices.append(i)
unique_palette = combined[unique_indices]
else:
# Neither has alpha - simple RGB merging
combined = np.vstack([palette1_float, palette2_float])
unique_palette = np.unique(combined, axis=0)
return unique_palette
def generate_palette(self, img, num_colors, algorithm=None, has_transparency=True):
"""Generate a palette using the selected quantization algorithm."""
algorithm = algorithm or self.ALGORITHM_HYBRID
if algorithm == self.ALGORITHM_PERCEPTUAL:
return self._generate_palette_perceptual(img, num_colors, has_transparency=has_transparency)
if algorithm == self.ALGORITHM_KMEANS_RGB:
return self._generate_palette_kmeans_rgb(img, num_colors, has_transparency=has_transparency)
if algorithm == self.ALGORITHM_SELECTIVE:
return self._generate_palette_selective(img, num_colors, has_transparency=has_transparency)
return self._generate_palette_hybrid(img, num_colors, has_transparency=has_transparency)
def apply_floyd_steinberg_dithering(self, img, palette, dithering_amount=1.0):
"""Apply Floyd-Steinberg dithering with variable amount and optimizations."""
# Ensure palette is float32 for internal processing
palette_float = palette.astype(np.float32)
if dithering_amount <= 0:
# If no dithering, just do direct color mapping (already vectorized)
has_alpha = img.shape[2] == 4
img_flat = img.reshape(-1, img.shape[2])
# Determine the transparent color index from the palette
transparent_idx = -1
if has_alpha: # img has alpha
# Check if palette ALSO has alpha before trying to access its alpha channel
if palette_float.shape[1] == 4:
alpha_channel = palette_float[:, 3]
transparent_indices = np.where(alpha_channel == 0.0)[0]
if len(transparent_indices) > 0:
transparent_idx = transparent_indices[0] # Take the first one
# else: palette is RGB, so no transparent_idx to find in it.
# Use palette RGB for distance calculation (will include -99999 if present)
palette_rgb = palette_float[:, :3]
if has_alpha:
# Handle transparent pixels in the source image
mask = img_flat[:, 3] < 1 # Check alpha channel of source image
# Find closest color for non-transparent pixels using palette_rgb
closest_colors_indices = np.zeros(img_flat.shape[0], dtype=int) # Placeholder shape
non_transparent_mask = ~mask
if np.any(non_transparent_mask):
closest_colors_non_transparent, indices_non_transparent = SmartImagePaletteConvert.find_closest_color_vectorized(
img_flat[non_transparent_mask, :3], palette_rgb
)
closest_colors_indices[non_transparent_mask] = indices_non_transparent
# Create result array (needs to be float initially if palette is float)
result_flat = np.zeros_like(img_flat, dtype=np.float32)
# Map non-transparent pixels using the full palette (incl alpha and modified RGB)
if np.any(non_transparent_mask):
result_flat[non_transparent_mask] = palette_float[closest_colors_indices[non_transparent_mask]]
# Set transparent pixels to the palette's transparent color (using index)
if transparent_idx != -1 and np.any(mask):
result_flat[mask] = palette_float[transparent_idx]
elif np.any(mask): # If transparent pixels exist but no transparent color in palette
# Fallback: Make them black with 0 alpha? Or handle error?
# Let's make them black and transparent
result_flat[mask, :3] = 0.0
result_flat[mask, 3] = 0.0
else:
# No transparency - simple mapping
closest_colors, indices = SmartImagePaletteConvert.find_closest_color_vectorized(img_flat, palette_rgb)
result_flat = palette_float[indices]
# Reshape and convert to uint8 at the end
return np.clip(result_flat, 0, 255).reshape(img.shape).astype(np.uint8)
# --- Use Numba for dithering > 0 ---
has_alpha = img.shape[2] == 4
img_float = img.astype(np.float32)
# Create a palette view without alpha for color comparisons if needed
# Numba needs contiguous arrays sometimes
palette_rgb = np.ascontiguousarray(palette_float[:, :3])
# Pre-calculate transparent index if needed
transparent_idx = -1
if has_alpha:
# Check if palette ALSO has alpha
if palette_float.shape[1] == 4:
alpha_channel = palette_float[:, 3]
transparent_indices = np.where(alpha_channel == 0.0)[0]
if len(transparent_indices) > 0:
transparent_idx = transparent_indices[0]
# else: # Handle case where no transparent color found if necessary
# print("Warning: No transparent color (alpha=0) found in palette for dithering.")
# Call the Numba JIT function (expects float32 palette)
result_dithered = _apply_floyd_steinberg_dithering_numba(
img_float,
palette_float, # Pass full float palette (with modified RGB, correct alpha)
palette_rgb, # Pass float RGB part (with modified RGB) for distance calc
dithering_amount,
has_alpha,
transparent_idx
)
# Numba function already returns uint8
return result_dithered
def process_image(self, image, palette, dithering_amount):
"""Process a single image with the given palette."""
# Palette is expected to be float32 here
return self.apply_floyd_steinberg_dithering(image, palette, dithering_amount)
def convert_image(self, image, num_colors, dithering_amount, algorithm, reference_image=None, additional_colors=None):
# Convert from tensor to numpy array
input_image = 255. * image.cpu().numpy()
# Process reference image if provided
if reference_image is not None:
reference_np = 255. * reference_image.cpu().numpy()
# Extract exact palette from reference image, ignoring num_colors
# This will return uint8 initially
palette_uint8 = self.extract_palette_from_reference(reference_np[0])
# Convert to float32 for internal processing
palette_float = palette_uint8.astype(np.float32)
# Ensure transparent color has modified RGB if present
if palette_float.shape[1] == 4: # Check if alpha channel exists
alpha_channel = palette_float[:, 3]
transparent_indices = np.where(alpha_channel == 0.0)[0]
if len(transparent_indices) > 0:
palette_float[transparent_indices, :3] = -99999.0
else:
# Generate optimal palette from the input image using num_colors
# Returns float32 palette with modified transparent color already
palette_float = self.generate_palette(input_image[0], num_colors, algorithm=algorithm)
# Process additional colors if provided
if additional_colors is not None:
additional_np = 255. * additional_colors.cpu().numpy()
# Extract exact palette from additional colors image
additional_palette_uint8 = self.extract_palette_from_reference(additional_np[0])
# Convert to float32 for processing
additional_palette_float = additional_palette_uint8.astype(np.float32)
# Ensure transparent color has modified RGB if present in additional palette
if additional_palette_float.shape[1] == 4:
alpha_channel = additional_palette_float[:, 3]
transparent_indices = np.where(alpha_channel == 0.0)[0]
if len(transparent_indices) > 0:
additional_palette_float[transparent_indices, :3] = -99999.0
# Merge the palettes while avoiding duplicates
palette_float = self.merge_palettes(palette_float, additional_palette_float)
# Create output array with same batch size as input
batch_size = input_image.shape[0]
# Use ThreadPoolExecutor for parallel processing of batch images
with ThreadPoolExecutor() as executor:
futures = [
# Pass the float palette to process_image
executor.submit(self.process_image, input_image[i], palette_float, dithering_amount)
for i in range(batch_size)
]
output_images = [future.result() for future in futures]
# Convert back to tensor
output_tensor = torch.from_numpy(np.stack(output_images) / 255.0).float()
# --- Restore transparent color RGB in the palette before output ---
# Work on a copy to not affect potential future uses if palette is cached etc.
final_palette = palette_float.copy()
if final_palette.shape[1] == 4: # Check if alpha channel exists
alpha_channel = final_palette[:, 3]
transparent_indices = np.where(alpha_channel == 0.0)[0]
if len(transparent_indices) > 0:
# Set RGB of transparent colors back to 0
final_palette[transparent_indices, :3] = 0.0
# Clip and convert the final palette to uint8 for the output tensor
final_palette_uint8 = np.clip(final_palette, 0, 255).astype(np.uint8)
# --- End Restoration ---
# Create palette image (1 pixel high, with each color in the **sorted** palette)
palette_height = 1
palette_width = len(final_palette_uint8)
# Create a NumPy array for the palette image using the restored uint8 palette
palette_img = np.zeros((palette_height, palette_width, final_palette_uint8.shape[1]), dtype=np.uint8)
# Fill each column with a color from the final uint8 palette
if palette_width > 0:
for i in range(palette_width):
palette_img[:, i] = final_palette_uint8[i]
# Convert to tensor (add batch dimension and normalize to 0-1 range)
palette_tensor = torch.from_numpy(palette_img[np.newaxis, ...] / 255.0).float()
return (output_tensor, palette_tensor)
class SmartImagesProcessor:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE",),
"cut_start": ("INT", {"default": 0, "min": 0, "step": 1}),
"cut_end": ("INT", {"default": 0, "min": 0, "step": 1}),
"fps_reduction_factor": ("INT", {"default": 1, "min": 1, "step": 1}),
},
"optional": {
# Keep type FLOAT, remove widget configuration to force dot input
"fps": ("FLOAT", ),
}
}
# Added IMAGE outputs for first and last frames
RETURN_TYPES = ("IMAGE", "FLOAT", "IMAGE", "IMAGE")
RETURN_NAMES = ("images", "fps", "first_frame", "last_frame") # Added names
FUNCTION = "process_images"
CATEGORY = "SmartImageTools"
# Default fps to None to detect if it was connected
def process_images(self, images, cut_start, cut_end, fps_reduction_factor, fps=None):
# If fps is not connected or is None, default to 0.0 for calculations
input_fps = fps if fps is not None else 0.0
batch_size = images.shape[0]
original_dtype = images.dtype
device = images.device
empty_frame_shape = (0,) + images.shape[1:] # Shape for empty frame tensor
# Ensure cut values are not negative
start_frame = max(0, cut_start)
end_frame_offset = max(0, cut_end)
# Calculate the slice indices for cutting, clamping to valid range
start_idx = min(start_frame, batch_size)
end_idx = max(start_idx, batch_size - end_frame_offset)
end_idx = min(end_idx, batch_size) # Ensure end_idx doesn't exceed batch_size
# Initialize output_fps as float 0.0, preserving floating-point precision
output_fps = 0.0
if input_fps > 0:
# If a valid FPS is input, use it directly (keep as float)
output_fps = input_fps
# Create empty tensors for outputs initially
empty_frames = torch.empty(empty_frame_shape, dtype=original_dtype, device=device)
empty_first_frame = torch.empty(empty_frame_shape, dtype=original_dtype, device=device)
empty_last_frame = torch.empty(empty_frame_shape, dtype=original_dtype, device=device)
if start_idx >= end_idx:
# Handle cases where the cuts result in no frames left
# Return an empty tensor with the correct shape dimensions except batch
print(f"Warning: Cut parameters ({cut_start}, {cut_end}) resulted in zero frames for batch size {batch_size}. Returning empty tensors.")
# Return empty tensor and original/default (potentially rounded) fps
# return (torch.empty(empty_frame_shape, dtype=original_dtype, device=device), output_fps)
return (empty_frames, output_fps, empty_first_frame, empty_last_frame)
# Apply start/end cut first
trimmed_images = images[start_idx:end_idx]
# Apply FPS reduction factor
if fps_reduction_factor > 1 and trimmed_images.shape[0] > 0:
processed_images = trimmed_images[::fps_reduction_factor]
# Calculate new FPS if original FPS was provided (>0)
if input_fps > 0:
# Use float division, ensure at least 1.0, keep as float
output_fps = max(1.0, input_fps / fps_reduction_factor)
# If original fps was 0 or less, keep output_fps as 0.0
if processed_images.shape[0] == 0:
# Handle cases where reduction factor removes all remaining frames
print(f"Warning: FPS reduction factor ({fps_reduction_factor}) resulted in zero frames after trimming. Returning empty tensors.")
# Return empty tensor and calculated/original fps
# return (torch.empty(empty_frame_shape, dtype=original_dtype, device=device), output_fps)
return (empty_frames, output_fps, empty_first_frame, empty_last_frame)
else:
processed_images = trimmed_images # No reduction needed or possible
# output_fps remains the same as input fps (potentially rounded initially)
# Extract first and last frames if images exist
if processed_images.shape[0] > 0:
first_frame = processed_images[0:1] # Slice to keep batch dimension
last_frame = processed_images[-1:] # Slice to keep batch dimension
else:
# This case should technically be caught earlier, but as a fallback
first_frame = empty_first_frame
last_frame = empty_last_frame
# Return output_fps as float
return (processed_images, output_fps, first_frame, last_frame)
class SmartGenerateImage:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"color": (["red", "green", "blue", "black", "white", "yellow", "cyan", "magenta",
"gray", "dark gray", "orange", "purple", "brown", "pink", "transparent"],),
"alpha": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"use_alpha": ("BOOLEAN", {"default": True}),
},
"optional": {
"width": ("INT", {"default": 512, "min": 1, "max": 8192, "step": 1}),
"height": ("INT", {"default": 512, "min": 1, "max": 8192, "step": 1}),
"get_image_size": ("IMAGE",),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "generate_image"
CATEGORY = "SmartImageTools"
def generate_image(self, width=512, height=512, color="white", alpha=1.0, use_alpha=True, get_image_size=None):
# Define color map with RGB(A) values
color_map = {
"red": [255, 0, 0],
"green": [0, 255, 0],
"blue": [0, 0, 255],
"black": [0, 0, 0],
"white": [255, 255, 255],
"yellow": [255, 255, 0],
"cyan": [0, 255, 255],
"magenta": [255, 0, 255],
"gray": [128, 128, 128],
"dark gray": [64, 64, 64],
"orange": [255, 165, 0],
"purple": [128, 0, 128],
"brown": [165, 42, 42],
"pink": [255, 192, 203],
"transparent": [0, 0, 0, 0], # Special case with alpha=0
}
# Get dimensions from input image if provided
if get_image_size is not None:
height, width = get_image_size.shape[1:3]
# Create image with or without alpha channel
channels = 4 if use_alpha else 3
# Handle special case for transparent color
if color == "transparent":
# Force alpha=0 for transparent color
alpha_value = 0
rgb_value = color_map["transparent"][:3]
else:
# Use the specified alpha
alpha_value = alpha
rgb_value = color_map[color]
# Create the image array
if channels == 4:
# With alpha channel
img_array = np.zeros((1, height, width, channels), dtype=np.float32)
img_array[0, :, :, :3] = np.array(rgb_value) / 255.0
img_array[0, :, :, 3] = alpha_value
else:
# RGB only
img_array = np.zeros((1, height, width, channels), dtype=np.float32)
img_array[0, :, :, :] = np.array(rgb_value) / 255.0
# Convert to tensor
tensor = torch.from_numpy(img_array).float()
return (tensor,)
class SmartBackgroundRemove:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"fill_size": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1}),
"tolerance": ("FLOAT", {"default": 0.01, "min": 0.0, "max": 1.0, "step": 0.01}),
"each_point_own_color": ("BOOLEAN", {"default": True}),
"debug_points": ("BOOLEAN", {"default": False}),
"clearEdges": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1}),
},
"optional": {
"start_point": ("POINT",),
"start_points": ("POINT_SET",),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "flood_remove"
CATEGORY = "SmartImageTools"
def color_distance(self, color1, color2, max_channels=3):
"""Calculate Euclidean distance between colors"""
return np.sqrt(np.sum((color1[:max_channels] - color2[:max_channels])**2))
def custom_flood_fill(self, img, start_coords, bg_color, tolerance, fill_size, edge_mask=None):
"""
Custom flood fill implementation using Numba for core logic and OpenCV for morphology.
Uses a specific background color.
"""
h, w = img.shape[:2]
channels = img.shape[2]
max_channels = min(3, channels) # Consider at most 3 channels (RGB) for distance
# Ensure input image is contiguous uint8 for Numba compatibility
img_uint8 = img.astype(np.uint8)
if not img_uint8.flags['C_CONTIGUOUS']:
img_uint8 = np.ascontiguousarray(img_uint8)
# Convert tolerance from 0-1 to actual color distance
# Use float32 for Numba compatibility
max_color_diff = np.float32(tolerance * 255.0 * np.sqrt(max_channels))
bg_color_float = bg_color.astype(np.float32) # Numba prefers consistent types
start_x, start_y = start_coords
# Call the Numba-optimized core flood fill logic
background_mask = _flood_fill_core_numba(
img_uint8,
start_x,
start_y,
bg_color_float,
max_color_diff,
max_channels
)
# --- Apply morphological operations outside Numba ---
if fill_size > 1:
# Ensure edge_mask exists if fill_size > 1 (should be handled by caller)
if edge_mask is None:
edge_mask = np.zeros((h, w), dtype=np.uint8)
edge_mask[:fill_size, :] = 1; edge_mask[-fill_size:, :] = 1
edge_mask[:, :fill_size] = 1; edge_mask[:, -fill_size:] = 1
# Create a kernel for morphological operations
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (fill_size, fill_size))
# Apply morphological closing to foreground (invert mask)
foreground_mask = 1 - background_mask # background_mask is already uint8
closed_foreground = cv2.morphologyEx(foreground_mask, cv2.MORPH_CLOSE, kernel)
# Calculate new background
new_background = 1 - closed_foreground
# Apply edge mask logic
edge_background = background_mask * edge_mask # Original fill in edge areas
non_edge_background = new_background * (1 - edge_mask) # Closed fill elsewhere
# Combine and ensure uint8
background_mask = np.clip(edge_background + non_edge_background, 0, 1).astype(np.uint8)
return background_mask # Return the final uint8 background mask
def flood_remove(self, image, fill_size, tolerance, each_point_own_color, debug_points, clearEdges=0, start_point=None, start_points=None):
# Convert from tensor to numpy array
input_image = 255. * image.cpu().numpy()
batch_size = input_image.shape[0]
output_images = []
# Determine which points to use
points_to_use = []
if start_points is not None:
points_to_use = start_points
elif start_point is not None:
points_to_use = [start_point]
else:
# Default if no points provided
points_to_use = [[0, 0]]
for i in range(batch_size):
img = input_image[i].copy()
h, w = img.shape[:2]
# Create initial mask: 1 for foreground (keep), 0 for background (remove)
mask = np.ones((h, w), dtype=np.uint8)
# Create edge mask for infinite corridor size near edges (shared by all points)
if fill_size > 1:
edge_mask = np.zeros((h, w), dtype=np.uint8)
edge_mask[:fill_size, :] = 1 # Top edge
edge_mask[-fill_size:, :] = 1 # Bottom edge
edge_mask[:, :fill_size] = 1 # Left edge
edge_mask[:, -fill_size:] = 1 # Right edge
else:
edge_mask = None
# Sample bg color from first point if not using individual colors
first_bg_color = None
if not each_point_own_color and len(points_to_use) > 0:
# Convert first point from normalized (0-1) to pixel coordinates
first_x = int(points_to_use[0][0] * w)
first_y = int((1 - points_to_use[0][1]) * h)
# Clamp coordinates to valid range
first_x = max(0, min(first_x, w - 1))
first_y = max(0, min(first_y, h - 1))
# Get background color from first point
first_bg_color = img[first_y, first_x].copy()
# Process each point
for point_idx, point in enumerate(points_to_use):
# Convert point from normalized (0-1) to pixel coordinates
x = int(point[0] * w)
y = int((1 - point[1]) * h) # Invert y as flood fill works from top-left
# Clamp coordinates to valid range
x = max(0, min(x, w - 1))
y = max(0, min(y, h - 1))
# Get background color from point or use first point's color
if each_point_own_color or point_idx == 0:
bg_color = img[y, x].copy()
else:
bg_color = first_bg_color
# Use custom flood fill with the sampled background color
fill_mask = self.custom_flood_fill(
img,
(x, y),
bg_color,
tolerance,
fill_size,
edge_mask
)
# Update the mask - any area filled should be considered background
# We use logical_and to keep track of areas that haven't been filled
mask = np.logical_and(mask, 1 - fill_mask).astype(np.uint8)
# Apply mask to image
if img.shape[2] == 4: # RGBA
# Just modify alpha channel
result = img.copy()
result[:, :, 3] = result[:, :, 3] * mask
else: # RGB
# For RGB, we need to add alpha channel
result = np.zeros((h, w, 4), dtype=np.uint8)
result[:, :, :3] = img
result[:, :, 3] = mask * 255
# Clear edge pixels to transparent
if clearEdges > 0:
ce = clearEdges
result[:ce, :, 3] = 0 # Top
result[-ce:, :, 3] = 0 # Bottom
result[:, :ce, 3] = 0 # Left
result[:, -ce:, 3] = 0 # Right
# Draw debug points if requested
if debug_points and points_to_use:
# Ensure result is uint8 for OpenCV drawing functions
result_uint8 = result.astype(np.uint8)
# Draw each point
for point in points_to_use:
# Convert point from normalized (0-1) to pixel coordinates
px = int(point[0] * w)
py = int((1 - point[1]) * h) # Invert y-coordinate
# Clamp coordinates to valid range
px = max(0, min(px, w - 1))
py = max(0, min(py, h - 1))
# Draw black outline first (3px radius)
cv2.circle(result_uint8, (px, py), 3, (0, 0, 0, 255), -1)
# Draw red point on top (2px radius)
cv2.circle(result_uint8, (px, py), 2, (255, 0, 0, 255), -1)
# Convert back to float32 for output
result = result_uint8.astype(np.float32)
output_images.append(result)
# Convert back to tensor
output_tensor = torch.from_numpy(np.stack(output_images) / 255.0).float()
return (output_tensor,)
class SmartPoint:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
"y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
}
}
RETURN_TYPES = ("POINT",)
FUNCTION = "create_point"
CATEGORY = "SmartImageTools"
def create_point(self, x, y):
return ([x, y],)
class SmartPointSet:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"point": ("POINT",),
}
}
RETURN_TYPES = ("POINT_SET",)
FUNCTION = "create_point_set"
CATEGORY = "SmartImageTools"
def create_point_set(self, point):
# Create a list containing just this point
return ([point],)
class SmartPointSetMerge:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"point_set": ("POINT_SET",),
"point": ("POINT",),
}
}
RETURN_TYPES = ("POINT_SET",)
FUNCTION = "merge_point"
CATEGORY = "SmartImageTools"
def merge_point(self, point_set, point):
# Create a new list with all existing points plus the new one
new_set = point_set.copy()
new_set.append(point)
return (new_set,)
class SmartImagePreviewScaled(PreviewImage):
def __init__(self):
super().__init__()
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE",),
# Change to FLOAT widget
"scale_by": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 100.0, "step": 0.1}),
"transparency": (["original", "black", "white", "orange"], {"default": "original"}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ()
FUNCTION = "execute" # Use execute which then calls save_images internally
OUTPUT_NODE = True
CATEGORY = "SmartImageTools"
# Use execute to potentially modify images before calling the parent's save logic
def execute(self, images, scale_by=1.0, transparency="original", filename_prefix="SmartScaledPreview", prompt=None, extra_pnginfo=None):
# Store original dimensions before any scaling
original_height = images.shape[1]
original_width = images.shape[2]
if abs(scale_by - 1.0) < 1e-6 or scale_by <= 0:
# No scaling or invalid scale factor, use original save_images
result = self.save_images(images, filename_prefix, prompt, extra_pnginfo)
# Add original dimensions to the result
if "ui" in result:
result["ui"]["original_dimensions"] = [[original_width, original_height]]
return result
# Perform scaling
scaled_images_list = []
original_dtype = images.dtype
device = images.device
for img_tensor in images: # Process each image in the batch
# Convert tensor (B, H, W, C) or (H, W, C) -> numpy (H, W, C)
img_np = img_tensor.cpu().numpy()
# Ensure it's float 0-1 range before converting to uint8
img_np = np.clip(img_np, 0.0, 1.0)
img_np_uint8 = (img_np * 255.0).astype(np.uint8)
h, w = img_np_uint8.shape[:2]
new_h = max(1, int(round(h * scale_by)))
new_w = max(1, int(round(w * scale_by)))
if h == 0 or w == 0:
# Handle empty image case - create an empty image of target size
resized_img = np.zeros((new_h, new_w, img_np_uint8.shape[2]), dtype=np.uint8)
else:
# Resize using nearest neighbor
resized_img = cv2.resize(img_np_uint8, (new_w, new_h), interpolation=cv2.INTER_NEAREST)
# --- Transparency Blending ---
if transparency != "original":
# Convert to PIL for alpha compositing
resized_pil = Image.fromarray(resized_img)
# Ensure image is RGBA for compositing
if resized_pil.mode != 'RGBA':
resized_pil = resized_pil.convert('RGBA')
# Get background color from BG_COLOR_MAP
if transparency in BG_COLOR_MAP and BG_COLOR_MAP[transparency] is not None:
blend_rgb = BG_COLOR_MAP[transparency]
# Create a background image of the solid color
background = Image.new('RGBA', resized_pil.size, blend_rgb + (255,))
# Composite the original image over the background
# This automatically handles the alpha blending
resized_pil = Image.alpha_composite(background, resized_pil)
# Convert back to numpy array
resized_img = np.array(resized_pil)
# --- End Transparency Blending ---
# Handle potential shape issues after resize (e.g., grayscale losing channel dim)
if len(resized_img.shape) == 2 and len(img_np_uint8.shape) == 3:
resized_img = np.expand_dims(resized_img, axis=-1) # Add channel dim back
# If original had more channels (e.g., RGB), replicate the channel
if img_np_uint8.shape[2] > 1:
resized_img = resized_img.repeat(img_np_uint8.shape[2], axis=-1)
elif len(resized_img.shape) == 3 and resized_img.shape[2] != img_np_uint8.shape[2]:
# If channel count changed unexpectedly, try to force match (e.g., add alpha or take RGB)
target_channels = img_np_uint8.shape[2]
if target_channels == 4 and resized_img.shape[2] == 3: # Add alpha
alpha_channel = np.full((new_h, new_w, 1), 255, dtype=np.uint8)
resized_img = np.concatenate((resized_img, alpha_channel), axis=-1)
elif target_channels == 3 and resized_img.shape[2] == 4: # Drop alpha
resized_img = resized_img[:, :, :3]
# Add other potential conversions if needed, otherwise, there might be an issue
# Convert back to float32 tensor [0, 1]
resized_img_float = resized_img.astype(np.float32) / 255.0
# Ensure tensor shape is HWC before converting
scaled_images_list.append(torch.from_numpy(resized_img_float).to(device).to(original_dtype))
# Stack the list of tensors back into a batch (B, H, W, C)
if not scaled_images_list: # Handle case where input images might be empty
return {"ui": {"images": []}} # Return empty preview data
scaled_images_batch = torch.stack(scaled_images_list)
# Call the parent's save_images method with the scaled images
result = self.save_images(scaled_images_batch, filename_prefix, prompt, extra_pnginfo)
# Add original dimensions to the result
if "ui" in result:
result["ui"]["original_dimensions"] = [[original_width, original_height]]
return result
class SmartPreviewPalette:
@classmethod
def INPUT_TYPES(s):
return {
"required": { "palette_image": ("IMAGE",) },
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ()
FUNCTION = "execute"
OUTPUT_NODE = True
CATEGORY = "SmartImageTools"
def execute(self, palette_image, prompt=None, extra_pnginfo=None):
colors_list = []
if palette_image is not None and palette_image.shape[0] > 0:
# Assuming BxHxWxC, and H=1
img_np = palette_image[0].cpu().numpy()
if img_np.shape[0] == 1: # Check if height is 1
# Iterate through the width
for w in range(img_np.shape[1]):
pixel = img_np[0, w, :] # Get pixel data (H=0, W=w)
# Convert float [0,1] to int [0,255]
rgb = (np.clip(pixel[:3], 0.0, 1.0) * 255).astype(np.uint8)
# Format as hex string #RRGGBB
hex_color = "#{:02x}{:02x}{:02x}".format(rgb[0], rgb[1], rgb[2])
colors_list.append(hex_color)
return {"ui": {"colors": [colors_list]}} # Wrap in another list to match PreviewImage structure
class SmartImagePoint:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
# Hidden input to store dot positions as JSON string
"points_data": ("STRING", {"default": '[{"x":0.5,"y":0.5}]', "multiline": False, "hidden": True}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
# Define outputs for the workflow graph
RETURN_TYPES = ("POINT", "POINT_SET")
RETURN_NAMES = ("first_point", "points")
FUNCTION = "get_points_and_update_ui"
OUTPUT_NODE = True
CATEGORY = "SmartImageTools"
# Instance method to access properties
def get_points_and_update_ui(self, image, points_data, prompt=None, extra_pnginfo=None):
# 1. Prepare UI data (image preview)
img_tensor = image[0]
h, w = img_tensor.shape[0:2]
img_np = np.clip(img_tensor.cpu().numpy(), 0.0, 1.0)
img_uint8 = (img_np * 255.0).astype(np.uint8)
pil_img = Image.fromarray(img_uint8)
buffer = io.BytesIO()
pil_img.save(buffer, format="PNG")
buffer.seek(0)
img_base64 = base64.b64encode(buffer.read()).decode('utf-8')
ui_data = {
"images": [f"data:image/png;base64,{img_base64}"],
"dimensions": [[w, h]],
"points_data": points_data # Send points_data to JS
}
# 2. Parse points_data JSON
try:
dots = json.loads(points_data)
if not isinstance(dots, list):
dots = [{'x': 0.5, 'y': 0.5}]
except Exception as e:
print(f"Error parsing points_data: {e}")
dots = [{'x': 0.5, 'y': 0.5}]
# Ensure dots have x and y
valid_dots = []
for dot in dots:
if isinstance(dot, dict) and 'x' in dot and 'y' in dot:
valid_dots.append(dot)
else:
# Add default if an invalid dot is found
valid_dots.append({'x': 0.5, 'y': 0.5})
if not valid_dots: # Ensure we always have at least one dot
valid_dots = [{'x': 0.5, 'y': 0.5}]
# 3. Format outputs
first_dot = valid_dots[0]
first_point = [first_dot.get('x', 0.5), first_dot.get('y', 0.5)]
point_set = [[d.get('x', 0.5), d.get('y', 0.5)] for d in valid_dots]
# Return results for the workflow graph AND data for the UI
return {"result": (first_point, point_set), "ui": ui_data}
class SmartImageRegion:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"type": (["Rectangle", "Polygon"],), # Added Polygon type
# Hidden input to store region data as JSON string
"region_data": ("STRING", {"default": '{"x1":0.25,"y1":0.25,"x2":0.75,"y2":0.75}', "multiline": False, "hidden": True}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
# Define outputs for the workflow graph
RETURN_TYPES = ("MASK", "BBOX")
RETURN_NAMES = ("mask", "bbox")
FUNCTION = "get_region_and_update_ui"
OUTPUT_NODE = True
CATEGORY = "SmartImageTools"
# Instance method to process region data
def get_region_and_update_ui(self, image, type, region_data, prompt=None, extra_pnginfo=None):
# 1. Prepare UI data (image preview)
img_tensor = image[0]
h, w = img_tensor.shape[0:2]
img_np = np.clip(img_tensor.cpu().numpy(), 0.0, 1.0)
img_uint8 = (img_np * 255.0).astype(np.uint8)
pil_img = Image.fromarray(img_uint8)
buffer = io.BytesIO()
pil_img.save(buffer, format="PNG")
buffer.seek(0)
img_base64 = base64.b64encode(buffer.read()).decode('utf-8')
ui_data = {
"images": [f"data:image/png;base64,{img_base64}"],
"dimensions": [[w, h]],
"region_data": region_data, # Send region_data to JS
"region_type": type # Send type to JS
}
# 2. Parse region_data JSON
try:
region = json.loads(region_data)
if type == "Rectangle" and not isinstance(region, dict):
region = {"x1": 0.25, "y1": 0.25, "x2": 0.75, "y2": 0.75}
elif type == "Polygon" and not isinstance(region, list):
# Default triangle if invalid polygon data
region = [{"x": 0.25, "y": 0.25}, {"x": 0.75, "y": 0.25}, {"x": 0.5, "y": 0.75}]
except Exception as e:
print(f"Error parsing region_data: {e}")
if type == "Rectangle":
region = {"x1": 0.25, "y1": 0.25, "x2": 0.75, "y2": 0.75}
else: # Polygon
region = [{"x": 0.25, "y": 0.25}, {"x": 0.75, "y": 0.25}, {"x": 0.5, "y": 0.75}]
# 3. Generate mask image
# Create a binary mask where the region is 1.0 and everywhere else is 0.0
# Standard MASK format is [batch, height, width] (no channels dimension)
mask_np = np.zeros((h, w), dtype=np.float32)
if type == "Rectangle":
# Get pixel coordinates (convert from 0-1 to pixel coords)
# Note: ComfyUI and our JS use top-left as origin with y increasing downward
# so we need to flip the y-coordinates for the mask
x1 = max(0, min(w-1, int(region.get("x1", 0.25) * w)))
y1 = max(0, min(h-1, int(region.get("y1", 0.25) * h))) # No y-flip for consistency with bbox
x2 = max(0, min(w-1, int(region.get("x2", 0.75) * w)))
y2 = max(0, min(h-1, int(region.get("y2", 0.75) * h))) # No y-flip for consistency with bbox
# Ensure x1 <= x2 and y1 <= y2
x1, x2 = min(x1, x2), max(x1, x2)
y1, y2 = min(y1, y2), max(y1, y2)
# Fill the region in the mask
mask_np[y1:y2+1, x1:x2+1] = 1.0
# 4. Create bbox output (normalized coordinates)
# Use original normalized coordinates from region data for consistency
x1_norm = region.get("x1", 0.25)
y1_norm = region.get("y1", 0.25)
x2_norm = region.get("x2", 0.75)
y2_norm = region.get("y2", 0.75)
# Ensure normalized coordinates are in correct order and range
x1_norm, x2_norm = min(x1_norm, x2_norm), max(x1_norm, x2_norm)
y1_norm, y2_norm = min(y1_norm, y2_norm), max(y1_norm, y2_norm)
x1_norm = max(0.0, min(1.0, x1_norm))
y1_norm = max(0.0, min(1.0, y1_norm))
x2_norm = max(0.0, min(1.0, x2_norm))
y2_norm = max(0.0, min(1.0, y2_norm))
# Calculate width and height
width_norm = x2_norm - x1_norm
height_norm = y2_norm - y1_norm
# BBOX format: [x, y, width, height] (normalized 0-1)
bbox = [x1_norm, y1_norm, width_norm, height_norm]
else: # Polygon type
# Convert normalized polygon points to pixel coordinates
if not region or len(region) < 3: # Ensure we have at least 3 points for a valid polygon
# Default triangle if invalid polygon data
region = [{"x": 0.25, "y": 0.25}, {"x": 0.75, "y": 0.25}, {"x": 0.5, "y": 0.75}]
poly_points = []
for point in region:
x = max(0, min(w-1, int(point.get("x", 0.5) * w)))
y = max(0, min(h-1, int(point.get("y", 0.5) * h)))
poly_points.append([x, y])
# Convert to numpy array for OpenCV
poly_points_np = np.array(poly_points, dtype=np.int32)
# Fill polygon in the mask using OpenCV
cv2.fillPoly(mask_np, [poly_points_np], 1.0)
# Calculate bounding box of the polygon
min_x, min_y = 1.0, 1.0
max_x, max_y = 0.0, 0.0
for point in region:
x_norm = max(0.0, min(1.0, point.get("x", 0.5)))
y_norm = max(0.0, min(1.0, point.get("y", 0.5)))
min_x = min(min_x, x_norm)
min_y = min(min_y, y_norm)
max_x = max(max_x, x_norm)
max_y = max(max_y, y_norm)
# Calculate width and height
width_norm = max_x - min_x
height_norm = max_y - min_y
# BBOX format: [x, y, width, height] (normalized 0-1)
bbox = [min_x, min_y, width_norm, height_norm]
# Convert to tensor with batch dimension [batch, height, width]
mask_tensor = torch.from_numpy(mask_np).unsqueeze(0)
# Return results for the workflow graph AND data for the UI
return {"result": (mask_tensor, bbox), "ui": ui_data}
class SmartImagePaletteExtract:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
},
"optional": {
"max_colors": ("INT", {"default": 255, "min": 1, "max": 255, "step": 1}),
}
}
RETURN_TYPES = ("IMAGE", "INT")
RETURN_NAMES = ("palette", "num_colors")
FUNCTION = "extract_palette"
CATEGORY = "SmartImageTools"
# Copied from SmartImagePaletteConvert for consistent sorting
@staticmethod
def _sort_palette(palette):
"""Sorts a palette primarily by hue, then luminance, then saturation.
Assigns special hue values for gray/transparent colors.
"""
if palette.shape[0] <= 1:
return palette
has_alpha = palette.shape[1] == 4
color_data = [] # List to store (sort_key, original_color)
for color_val in palette:
rgb = color_val[:3] / 255.0
alpha = color_val[3] / 255.0 if has_alpha else 1.0
is_transparent = has_alpha and color_val[3] == 0
h, l, s = colorsys.rgb_to_hls(rgb[0], rgb[1], rgb[2])
# Define hue_value for sorting
# -2: Transparent
# -1: Achromatic (Gray/Black/White)
# 0-1: Chromatic hue
if is_transparent:
hue_value = -2
l = 0.0 # Use luminance of black for sorting transparent
s = 0.0
elif s < 0.05 or l < 0.01 or l > 0.99: # Achromatic threshold
hue_value = -1
else:
hue_value = h
sort_key = (hue_value, l, s)
color_data.append((sort_key, color_val))
color_data.sort(key=lambda x: x[0])
sorted_palette_list = [item[1] for item in color_data]
return np.array(sorted_palette_list, dtype=palette.dtype)
def extract_palette(self, image, max_colors=255):
input_image = 255. * image.cpu().numpy()
# Process only the first image in the batch
img = input_image[0].astype(np.uint8)
h, w, channels = img.shape
has_alpha = channels == 4
# Reshape to get all pixels
img_flat = img.reshape(-1, channels)
# Get unique colors
unique_colors = np.unique(img_flat, axis=0)
num_unique = len(unique_colors)
if num_unique > max_colors:
raise ValueError(f"Image contains {num_unique} unique colors, exceeding the limit of {max_colors}.")
# Sort the palette
sorted_palette = self._sort_palette(unique_colors)
# Create palette image (1 pixel high)
palette_height = 1
palette_width = len(sorted_palette)
palette_img = np.zeros((palette_height, palette_width, channels), dtype=np.uint8)
if palette_width > 0:
for i in range(palette_width):
palette_img[:, i] = sorted_palette[i]
# Convert to tensor (add batch dimension and normalize)
palette_tensor = torch.from_numpy(palette_img[np.newaxis, ...] / 255.0).float()
return (palette_tensor, num_unique)
class SmartSemiTransparenceRemove:
# Define color map with RGB values (0-1 range for easier blending)
COLOR_MAP = {
"red": [1.0, 0.0, 0.0],
"green": [0.0, 1.0, 0.0],
"blue": [0.0, 0.0, 1.0],
"black": [0.0, 0.0, 0.0],
"white": [1.0, 1.0, 1.0],
"yellow": [1.0, 1.0, 0.0],
"cyan": [0.0, 1.0, 1.0],
"magenta": [1.0, 0.0, 1.0],
"gray": [0.5, 0.5, 0.5],
"dark gray": [0.25, 0.25, 0.25],
"orange": [1.0, 0.647, 0.0],
"purple": [0.5, 0.0, 0.5],
"brown": [0.647, 0.165, 0.165],
"pink": [1.0, 0.753, 0.796],
}
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"blend_color": (list(s.COLOR_MAP.keys()),),
"threshold": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}),
"over_threshold": (["Blend", "Fill blend color"],),
"fill_threshold": ("FLOAT", {"default": 0.95, "min": 0.0, "max": 1.0, "step": 0.01}),
},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "remove_transparence"
CATEGORY = "SmartImageTools"
def remove_transparence(self, image, blend_color, threshold, over_threshold="Blend", fill_threshold=0.95):
batch_size, height, width, channels = image.shape
device = image.device
dtype = image.dtype
# Get blend color RGB values
blend_rgb = torch.tensor(self.COLOR_MAP[blend_color], device=device, dtype=dtype)
# Clone image to avoid modifying original tensor
result_image = image.clone()
# Ensure image has alpha channel
if channels == 3:
# Add alpha channel, initialize to fully opaque
alpha_channel = torch.ones((batch_size, height, width, 1), device=device, dtype=dtype)
result_image = torch.cat((result_image, alpha_channel), dim=3)
elif channels != 4:
raise ValueError("Input image must have 3 (RGB) or 4 (RGBA) channels.")
# Process pixels
alpha = result_image[:, :, :, 3]
rgb = result_image[:, :, :, :3]
# Condition 1: Alpha is 1 (fully opaque) - do nothing, alpha already 1
# Condition 2: Alpha < threshold - make fully transparent
mask_transparent = alpha < threshold # Shape [B, H, W]
num_transparent_pixels = mask_transparent.sum()
if num_transparent_pixels > 0:
# Set alpha channel to 0 using masked assignment on the slice
result_image[:, :, :, 3][mask_transparent] = 0.0
# Set RGB channels to 0 using masked assignment on the slice
result_image[:, :, :, :3][mask_transparent] = 0.0
if over_threshold == "Blend":
# Original behavior: blend all semi-transparent pixels
# Condition 3: threshold <= Alpha < 1 - blend and make fully opaque
mask_blend = (alpha >= threshold) & (alpha < 1.0) # Shape [B, H, W]
num_blend_pixels = mask_blend.sum()
if num_blend_pixels > 0:
# Expand alpha and blend_rgb for broadcasting
alpha_expanded = alpha[mask_blend].unsqueeze(-1) # Shape: [num_blend_pixels, 1]
blend_rgb_expanded = blend_rgb.unsqueeze(0) # Shape: [1, 3]
# Calculate blended RGB for the relevant pixels
original_rgb_subset = rgb[mask_blend] # Shape: [num_blend_pixels, 3]
blended_rgb = original_rgb_subset * alpha_expanded + blend_rgb_expanded * (1.0 - alpha_expanded) # Shape: [num_blend_pixels, 3]
# Assign blended RGB back to the result image using the mask on the sliced tensor
result_image[:, :, :, :3][mask_blend] = blended_rgb
# Set alpha to 1 for these pixels using the mask on the sliced tensor
result_image[:, :, :, 3][mask_blend] = 1.0
else:
# "Fill blend color" mode
# Pixels with threshold <= alpha < fill_threshold: filled with blend color
mask_fill = (alpha >= threshold) & (alpha < fill_threshold) # Shape [B, H, W]
num_fill_pixels = mask_fill.sum()
if num_fill_pixels > 0:
# Fill with blend color (no blending)
result_image[:, :, :, :3][mask_fill] = blend_rgb.unsqueeze(0).unsqueeze(0).unsqueeze(0)
# Set alpha to 1
result_image[:, :, :, 3][mask_fill] = 1.0
# Pixels with fill_threshold <= alpha < 1.0: blended normally
mask_blend = (alpha >= fill_threshold) & (alpha < 1.0) # Shape [B, H, W]
num_blend_pixels = mask_blend.sum()
if num_blend_pixels > 0:
# Expand alpha and blend_rgb for broadcasting
alpha_expanded = alpha[mask_blend].unsqueeze(-1) # Shape: [num_blend_pixels, 1]
blend_rgb_expanded = blend_rgb.unsqueeze(0) # Shape: [1, 3]
# Calculate blended RGB for the relevant pixels
original_rgb_subset = rgb[mask_blend] # Shape: [num_blend_pixels, 3]
blended_rgb = original_rgb_subset * alpha_expanded + blend_rgb_expanded * (1.0 - alpha_expanded) # Shape: [num_blend_pixels, 3]
# Assign blended RGB back to the result image using the mask on the sliced tensor
result_image[:, :, :, :3][mask_blend] = blended_rgb
# Set alpha to 1 for these pixels using the mask on the sliced tensor
result_image[:, :, :, 3][mask_blend] = 1.0
return (result_image,)
# Color map for background blending (0-255 range)
BG_COLOR_MAP = {
"transparent": None, # Special case
"red": (255, 0, 0),
"green": (0, 255, 0),
"blue": (0, 0, 255),
"black": (0, 0, 0),
"white": (255, 255, 255),
"yellow": (255, 255, 0),
"cyan": (0, 255, 255),
"magenta": (255, 0, 255),
"gray": (128, 128, 128),
"dark gray": (64, 64, 64),
"orange": (255, 165, 0),
"purple": (128, 0, 128),
"brown": (165, 42, 42),
"pink": (255, 192, 203),
}
class SmartVideoPreviewScaled:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE",),
"fps": ("FLOAT", {"default": 12.0, "min": 0.1, "max": 60.0, "step": 1}),
"scale_by": ("FLOAT", {"default": 2.0, "min": 0.1, "max": 10.0, "step": 0.1}),
"bg_color": (list(BG_COLOR_MAP.keys()), {"default": "transparent"}), # Added bg_color
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ()
FUNCTION = "preview_video"
OUTPUT_NODE = True
CATEGORY = "SmartNodes/Image"
@classmethod
def IS_CHANGED(s, images, fps, scale_by, bg_color, prompt=None, extra_pnginfo=None):
# Include all parameters that affect the output to prevent incorrect caching
# Create a hash of the parameters to ensure unique cache keys
m = hashlib.sha256()
m.update(str(fps).encode())
m.update(str(scale_by).encode())
m.update(str(bg_color).encode())
# Add a random component to force re-execution for preview nodes
# This ensures that even with identical parameters, preview updates properly
m.update(str(random.random()).encode())
return m.digest().hex()
def preview_video(self, images, fps, scale_by=2.0, bg_color="transparent", prompt=None, extra_pnginfo=None):
# Convert tensors to PIL images and prepare for web UI
image_data = []
original_dimensions = []
scaled_dimensions = []
for image_tensor in images:
img_np = image_tensor.cpu().numpy()
# Handle potential non-standard ranges by clipping before conversion
img_np = np.clip(img_np, 0.0, 1.0)
img_pil = Image.fromarray((img_np * 255.0).astype(np.uint8))
# Store original dimensions
orig_w, orig_h = img_pil.size
original_dimensions.append([orig_w, orig_h])
# --- Scaling happens here ---
if abs(scale_by - 1.0) > 1e-6 and scale_by > 0:
new_w = max(1, int(round(orig_w * scale_by)))
new_h = max(1, int(round(orig_h * scale_by)))
# Resize using NEAREST neighbor
img_pil = img_pil.resize((new_w, new_h), Image.NEAREST)
else:
# If no scaling, scaled dimensions are same as original
new_w, new_h = orig_w, orig_h
# --- End Scaling ---
# Store scaled dimensions
scaled_dimensions.append([new_w, new_h])
# --- Background Blending ---
if bg_color != "transparent" and bg_color in BG_COLOR_MAP:
blend_rgb = BG_COLOR_MAP[bg_color]
if blend_rgb is not None:
# Ensure image is RGBA for compositing
if img_pil.mode != 'RGBA':
img_pil = img_pil.convert('RGBA')
# Create a background image of the solid color
background = Image.new('RGBA', img_pil.size, blend_rgb + (255,))
# Composite the original image over the background
# This automatically handles the alpha blending
img_pil = Image.alpha_composite(background, img_pil)
# --- End Background Blending ---
# Save the (potentially scaled and blended) image to a temporary location
output_dir = folder_paths.get_temp_directory()
filename = f"smart_video_frame_{random.randint(1000000, 9999999)}.png" # Add randomness to filename
file_path = os.path.join(output_dir, filename) # Use os.path.join
img_pil.save(file_path)
image_data.append({
"filename": filename,
"subfolder": "", # Saved directly in temp
"type": "temp"
})
# Return images, fps, and both original and scaled dimensions
# Use a unique key 'video_frames' to avoid conflicts with default preview handlers
return {"ui": {
"video_frames": image_data,
"fps": [fps],
"original_dimensions": original_dimensions,
"scaled_dimensions": scaled_dimensions
}}
class SmartVideoPreviewScaledMasked:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE",),
"fps": ("FLOAT", {"default": 12.0, "min": 0.1, "max": 60.0, "step": 1}),
"scale_by": ("FLOAT", {"default": 2.0, "min": 0.1, "max": 10.0, "step": 0.1}),
"mask_color": ("STRING", {"default": "#FF0000FF"}), # RGBA color as hex
},
"optional": {
"mask": ("MASK",),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("composed_image",)
FUNCTION = "preview_video"
OUTPUT_NODE = True
CATEGORY = "SmartNodes/Image"
@classmethod
def IS_CHANGED(s, images, fps, scale_by, mask_color, mask=None, prompt=None, extra_pnginfo=None):
# Include all parameters to prevent incorrect caching
m = hashlib.sha256()
m.update(str(fps).encode())
m.update(str(scale_by).encode())
m.update(str(mask_color).encode())
# Add random component for preview nodes
m.update(str(random.random()).encode())
return m.digest().hex()
def hex_to_rgba(self, hex_color):
"""Convert hex color string to RGBA tuple (0-255 range)."""
hex_color = hex_color.lstrip('#')
if len(hex_color) == 6:
# RGB only, add full opacity
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16)
a = 255
elif len(hex_color) == 8:
# RGBA
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16)
a = int(hex_color[6:8], 16)
else:
# Default to red if invalid
r, g, b, a = 255, 0, 0, 255
return (r, g, b, a)
def preview_video(self, images, fps, scale_by=2.0, mask_color="#FF0000FF", mask=None, prompt=None, extra_pnginfo=None):
# Convert tensors to numpy
images_np = images.cpu().numpy()
mask_np = mask.cpu().numpy() if mask is not None else None
# Parse mask color
rgba_color = self.hex_to_rgba(mask_color)
image_data = []
original_dimensions = []
scaled_dimensions = []
composed_images = []
batch_size = images_np.shape[0]
for i in range(batch_size):
# Get image
img_np = images_np[i]
img_np = np.clip(img_np, 0.0, 1.0)
# Convert image to uint8
img_uint8 = (img_np * 255.0).astype(np.uint8)
h, w = img_uint8.shape[:2]
# Convert to PIL
img_pil = Image.fromarray(img_uint8)
# Store original dimensions
orig_w, orig_h = img_pil.size
original_dimensions.append([orig_w, orig_h])
# Apply mask overlay if mask is provided
if mask_np is not None:
# Get mask (handle single mask or batch)
if i < mask_np.shape[0]:
mask_frame = mask_np[i]
else:
mask_frame = mask_np[0] # Use first mask if batch is smaller
# Resize mask to match image dimensions if needed
if mask_frame.shape[0] != h or mask_frame.shape[1] != w:
mask_frame = cv2.resize(mask_frame, (w, h), interpolation=cv2.INTER_LINEAR)
# Ensure image is RGBA for compositing
if img_pil.mode != 'RGBA':
img_pil = img_pil.convert('RGBA')
# Create mask overlay layer
# mask_frame values are 0-1, where 1 = fully masked
mask_overlay = np.zeros((h, w, 4), dtype=np.uint8)
mask_overlay[:, :, 0] = rgba_color[0] # R
mask_overlay[:, :, 1] = rgba_color[1] # G
mask_overlay[:, :, 2] = rgba_color[2] # B
# Alpha channel of overlay = mask * color_alpha
mask_overlay[:, :, 3] = (mask_frame * rgba_color[3]).astype(np.uint8)
# Convert mask overlay to PIL
mask_overlay_pil = Image.fromarray(mask_overlay, 'RGBA')
# Composite: base image + mask overlay
img_pil = Image.alpha_composite(img_pil, mask_overlay_pil)
# --- Scaling happens here ---
if abs(scale_by - 1.0) > 1e-6 and scale_by > 0:
new_w = max(1, int(round(orig_w * scale_by)))
new_h = max(1, int(round(orig_h * scale_by)))
# Resize using NEAREST neighbor
img_pil = img_pil.resize((new_w, new_h), Image.NEAREST)
else:
# If no scaling, scaled dimensions are same as original
new_w, new_h = orig_w, orig_h
# --- End Scaling ---
# Store scaled dimensions
scaled_dimensions.append([new_w, new_h])
# Convert back to tensor for output
img_array = np.array(img_pil).astype(np.float32) / 255.0
composed_images.append(torch.from_numpy(img_array))
# Save the image to a temporary location for preview
output_dir = folder_paths.get_temp_directory()
filename = f"smart_video_masked_{random.randint(1000000, 9999999)}.png"
file_path = os.path.join(output_dir, filename)
img_pil.save(file_path)
image_data.append({
"filename": filename,
"subfolder": "",
"type": "temp"
})
# Stack composed images into batch
composed_batch = torch.stack(composed_images)
# Return with both output tensor and UI preview
return {
"ui": {
"video_frames": image_data,
"fps": [fps],
"original_dimensions": original_dimensions,
"scaled_dimensions": scaled_dimensions
},
"result": (composed_batch,)
}
class SmartSaveAnimatedPNG:
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
self.type = "output"
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE", ),
"fps": ("FLOAT", {"default": 12.0, "min": 0.1, "max": 1000.0, "step": 0.1}),
"filename_prefix": ("STRING", {"default": "ComfyUI"}),
# Keep lossy_quality for potential future use, but set default high
"lossless": ("BOOLEAN", {"default": True}),
"save_metadata": ("BOOLEAN", {"default": True}),
"format": (["APNG", "PNGif", "GIF"], {"default": "APNG"}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ()
FUNCTION = "save_apng"
OUTPUT_NODE = True
CATEGORY = "SmartImageTools"
def save_animated_gif(self, images, filename, fps, loop, optimize, quality, preserve_transparency, alpha_threshold):
try:
from PIL import Image
import numpy as np
import torch
except ImportError as e:
raise RuntimeError(f"Required libraries not available: {e}. Please install Pillow.")
# Ensure filename has .gif extension
if not filename.lower().endswith('.gif'):
if filename.endswith('.'):
filename = filename + 'gif'
else:
filename = filename + '.gif'
output_dir = folder_paths.get_output_directory()
if os.path.isabs(filename):
full_path = filename
else:
full_path = os.path.join(output_dir, filename)
# Create directories if they don't exist
directory = os.path.dirname(full_path)
if directory:
os.makedirs(directory, exist_ok=True)
# Convert images to PIL format
pil_frames = []
has_transparent_pixel = False
used_colors = set()
alpha_thresh = alpha_threshold * 255
if isinstance(images, torch.Tensor) and images.dim() == 4:
# Handle batch of images
batch_size = images.shape[0]
else:
# If not a batch tensor, treat as list
batch_size = len(images)
for i in range(batch_size):
if isinstance(images, torch.Tensor):
img = images[i]
else:
img = images[i]
if isinstance(img, torch.Tensor):
img = img.detach().cpu()
if img.dim() == 3:
if img.shape[0] in [1, 3, 4]: # CHW
img = img.permute(1, 2, 0)
if img.max() <= 1.0:
img = img * 255
img = img.round().clamp(0, 255).byte().numpy()
mode = 'RGBA' if img.shape[-1] == 4 else 'RGB' if img.shape[-1] == 3 else 'L' if img.shape[-1] == 1 else None
if mode is None:
raise ValueError(f"Unsupported tensor channel count: {img.shape[-1]}")
if mode == 'L':
img = np.repeat(img[:, :, np.newaxis], 3, axis=-1)
mode = 'RGB'
pil_img = Image.fromarray(img, mode)
elif isinstance(img, np.ndarray):
if img.max() <= 1.0:
img = img * 255
img = np.round(img).clip(0, 255).astype(np.uint8)
mode = 'RGBA' if img.shape[-1] == 4 else 'RGB' if img.shape[-1] == 3 else 'L' if img.shape[-1] == 1 else None
if mode is None:
raise ValueError(f"Unsupported array channel count: {img.shape[-1]}")
if mode == 'L':
img = np.repeat(img[:, :, np.newaxis], 3, axis=-1)
mode = 'RGB'
pil_img = Image.fromarray(img, mode)
elif isinstance(img, Image.Image):
pil_img = img
else:
raise ValueError(f"Unsupported image type: {type(img)}")
# Convert to RGBA for consistency if transparency might be present
if pil_img.mode not in ['RGB', 'RGBA']:
pil_img = pil_img.convert('RGB')
if preserve_transparency and pil_img.mode == 'RGBA':
data = np.array(pil_img)
alpha = data[:, :, 3]
if np.any(alpha < alpha_thresh):
has_transparent_pixel = True
opaque_mask = alpha >= alpha_thresh
opaque_rgb = data[opaque_mask, :3]
for color in opaque_rgb:
used_colors.add(tuple(map(int, color)))
pil_frames.append(pil_img)
if not pil_frames:
raise ValueError("No valid images provided")
if not preserve_transparency or not has_transparent_pixel:
# Composite to RGB with white background
for i in range(len(pil_frames)):
if pil_frames[i].mode == 'RGBA':
background = Image.new('RGB', pil_frames[i].size, (255, 255, 255))
background.paste(pil_frames[i], mask=pil_frames[i].getchannel('A'))
pil_frames[i] = background
pil_images = pil_frames
else:
# Find global key_color not used in any opaque pixel
key_color = None
attempts = 0
while attempts < 10000 and key_color is None:
r = random.randint(0, 255)
g = random.randint(0, 255)
b = random.randint(0, 255)
if (r, g, b) not in used_colors:
key_color = (r, g, b)
attempts += 1
if key_color is None:
key_color = (255, 0, 255)
# Replace occurrences of key_color in opaque areas
for i in range(len(pil_frames)):
if pil_frames[i].mode == 'RGBA':
data = np.array(pil_frames[i])
alpha = data[:, :, 3]
match = (data[:, :, 0] == 255) & (data[:, :, 1] == 0) & (data[:, :, 2] == 255) & (alpha >= alpha_thresh)
data[match, 0] = 254
pil_frames[i] = Image.fromarray(data, 'RGBA')
# Create RGB frames with key_color in transparent areas
rgb_frames = []
for frame in pil_frames:
if frame.mode == 'RGBA':
data = np.array(frame)
transparent_mask = data[:, :, 3] < alpha_thresh
rgb_data = data[:, :, :3]
rgb_data[transparent_mask] = key_color
frame_rgb = Image.fromarray(rgb_data, 'RGB')
else:
frame_rgb = frame.convert('RGB')
rgb_frames.append(frame_rgb)
# Create large image for global palette
widths, heights = zip(*(f.size for f in rgb_frames))
max_width = max(widths)
total_height = sum(heights) + 32 # Extra for key block
large_img = Image.new('RGB', (max_width, total_height))
y = 0
for f in rgb_frames:
large_img.paste(f, (0, y))
y += f.height
key_block = Image.new('RGB', (32, 32), key_color)
large_img.paste(key_block, (0, y))
# Quantize large image
large_quant = large_img.quantize(colors=256, method=Image.Quantize.MAXCOVERAGE)
global_palette = large_quant.getpalette()[:256*3]
# Find trans_index
trans_index = -1
for i in range(256):
if (global_palette[i*3] == key_color[0] and
global_palette[i*3 + 1] == key_color[1] and
global_palette[i*3 + 2] == key_color[2]):
trans_index = i
break
if trans_index == -1:
# Fallback to per-frame palettes
pil_images = []
for frame_rgb in rgb_frames:
frame_p = frame_rgb.quantize(colors=256)
palette = frame_p.getpalette()[:256*3]
for j in range(256):
if (palette[j*3] == key_color[0] and
palette[j*3 + 1] == key_color[1] and
palette[j*3 + 2] == key_color[2]):
frame_p.info['transparency'] = j
frame_p.info['disposal'] = 2
break
pil_images.append(frame_p)
if pil_images:
pil_images[0].info['background'] = pil_images[0].info.get('transparency', 0)
else:
# Use global palette
palette_img = Image.new('P', (1, 1))
palette_img.putpalette(global_palette)
pil_images = []
for frame_rgb in rgb_frames:
frame_p = frame_rgb.quantize(palette=palette_img)
frame_p.info['transparency'] = trans_index
frame_p.info['disposal'] = 2
pil_images.append(frame_p)
if pil_images:
pil_images[0].info['background'] = trans_index
# Calculate duration per frame in milliseconds
duration = int(1000 / fps)
# Save as animated GIF
pil_images[0].save(
full_path,
save_all=True,
append_images=pil_images[1:],
duration=duration,
loop=loop,
optimize=optimize,
quality=quality
)
return (full_path,)
def save_apng(self, images, fps, filename_prefix="ComfyUI", lossless=True, save_metadata=True, format="APNG", prompt=None, extra_pnginfo=None):
abs_prefix = os.path.abspath(filename_prefix)
is_absolute = os.path.isabs(filename_prefix) or (len(filename_prefix) >= 2 and filename_prefix[1] == ':')
cross_drive = is_absolute and os.path.splitdrive(abs_prefix)[0].lower() != os.path.splitdrive(self.output_dir)[0].lower()
if cross_drive:
full_output_folder = os.path.dirname(abs_prefix)
base_filename = os.path.basename(abs_prefix)
subfolder = ""
os.makedirs(full_output_folder, exist_ok=True)
else:
full_output_folder, filename, counter, subfolder, filename_prefix_ = folder_paths.get_save_image_path(filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0])
base_filename = os.path.basename(filename_prefix_)
# Use a single filename, not numbered sequence
if format == "APNG":
ext = "apng"
elif format == "PNGif":
ext = "pngif"
else:
ext = "gif"
file = f"{base_filename}.{ext}" # Use the extracted base name
file_path = os.path.join(full_output_folder, file)
# GIF branch: delegate to save_animated_gif
if format == "GIF":
results = list()
try:
if cross_drive:
relative_filename = file_path
else:
relative_filename = os.path.join(subfolder, file) if subfolder else file
self.save_animated_gif(
images=images,
filename=relative_filename,
fps=fps,
loop=0,
optimize=True,
quality=95,
preserve_transparency=True,
alpha_threshold=0.01,
)
results.append({
"filename": file,
"subfolder": subfolder,
"type": self.type
})
except Exception as e:
print(f"Error saving GIF file: {e}")
raise
return {"ui": {"apng": results}}
num_frames = images.shape[0]
fps_int = int(round(fps * 100)) # Multiply by 100 and convert to int
results = list()
try:
with open(file_path, 'wb') as f:
# Write header: num_frames (int), fps_int (int)
# Use '<i' for 4-byte signed integer, little-endian
f.write(struct.pack('<i', num_frames))
f.write(struct.pack('<i', fps_int))
# Process and write each frame
for image_tensor in images:
# Convert tensor [0, 1] float -> numpy [0, 255] uint8
img_np = image_tensor.cpu().numpy()
img_np = np.clip(img_np, 0.0, 1.0)
img_uint8 = (img_np * 255.0).astype(np.uint8)
img_pil = Image.fromarray(img_uint8)
# Save PIL image to bytes buffer as PNG
buffer = io.BytesIO()
# Determine PNG save options based on lossless flag
pnginfo = None
if save_metadata and prompt is not None:
pnginfo = PngInfo()
if extra_pnginfo is not None:
for key in extra_pnginfo:
pnginfo.add_text(key, json.dumps(extra_pnginfo[key]))
pnginfo.add_text("prompt", json.dumps(prompt))
save_opts = {"pnginfo": pnginfo}
if not lossless:
# While PNG is typically lossless, PIL allows 'compress_level'
# 0 = no compression, 1 = fastest, 9 = best compression (slowest)
# Let's default to a moderate level if lossless=False, though it's still lossless compression
# If a truly lossy format were desired, we'd need WebP or similar.
# For simplicity here, we just vary compression level.
save_opts["compress_level"] = 4 # Moderate compression if "lossless" is false
else:
save_opts["compress_level"] = 1 # Faster compression if lossless is true
img_pil.save(buffer, format="PNG", **save_opts)
png_data = buffer.getvalue()
png_length = len(png_data)
# Write frame data: png_length (int), png_data (binary)
f.write(struct.pack('<i', png_length))
f.write(png_data)
results.append({
"filename": file,
"subfolder": subfolder,
"type": self.type
})
except Exception as e:
print(f"Error saving custom APNG file: {e}")
# Optionally re-raise or handle the error appropriately
raise
# Return UI data indicating the saved file
return {"ui": {"apng": results}}
class SmartSavePNG:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE", ),
"filename": ("STRING", {"default": "C:/output.png"}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "save_png"
OUTPUT_NODE = True
CATEGORY = "SmartImageTools"
def save_png(self, images, filename, prompt=None, extra_pnginfo=None):
# Get the first image from the batch
image_tensor = images[0] if len(images.shape) > 3 else images
# Convert tensor [0, 1] float -> numpy [0, 255] uint8
img_np = image_tensor.cpu().numpy()
img_np = np.clip(img_np, 0.0, 1.0)
img_uint8 = (img_np * 255.0).astype(np.uint8)
img_pil = Image.fromarray(img_uint8)
# Create PngInfo metadata if needed
pnginfo = None
if prompt is not None:
pnginfo = PngInfo()
if extra_pnginfo is not None:
for key in extra_pnginfo:
pnginfo.add_text(key, json.dumps(extra_pnginfo[key]))
pnginfo.add_text("prompt", json.dumps(prompt))
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(os.path.abspath(filename)), exist_ok=True)
# Save the image
img_pil.save(filename, format="PNG", pnginfo=pnginfo)
return (images,)
class SmartDrawPoints:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"points": ("POINT_SET",),
"color": (list(BG_COLOR_MAP.keys()),),
"circle_size": ("INT", {"default": 5, "min": 1, "max": 100, "step": 1}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "draw_points"
CATEGORY = "SmartImageTools"
def draw_points(self, image, points, color, circle_size):
# Convert from tensor to numpy array
input_image = 255. * image.cpu().numpy()
batch_size = input_image.shape[0]
output_images = []
for i in range(batch_size):
img = input_image[i].copy()
h, w = img.shape[:2]
# Convert image to uint8 for OpenCV
img_uint8 = img.astype(np.uint8)
# Get color values
if color == "transparent":
# For transparent, use black with alpha=0
circle_color = (0, 0, 0, 0)
else:
# Get RGB values and add alpha=255
rgb = BG_COLOR_MAP[color]
circle_color = rgb + (255,)
# Draw circles for each point
for point in points:
# Convert point from normalized (0-1) to pixel coordinates
x = int(point[0] * w)
y = int((1 - point[1]) * h) # Invert y as OpenCV uses top-left origin
# Clamp coordinates to valid range
x = max(0, min(x, w - 1))
y = max(0, min(y, h - 1))
# Draw filled circle
cv2.circle(img_uint8, (x, y), circle_size, circle_color, -1)
output_images.append(img_uint8)
# Convert back to tensor
output_tensor = torch.from_numpy(np.stack(output_images) / 255.0).float()
return (output_tensor,)
class SmartLoadGIFImage:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
# STRING path (absolute or Comfy input-relative) so API clients
# like SmartComfyBridge can pass task-folder GIFs. The old combo
# dropdown only listed files already inside input/ and rejected
# absolute paths at prompt validation.
"image": ("STRING", {"default": "", "multiline": False,
"placeholder": "X://path/to/file.gif"}),
"custom_frame": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1}),
},
}
CATEGORY = "SmartImageTools"
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE", "MASK", "IMAGE", "MASK", "IMAGE", "MASK")
RETURN_NAMES = ("all_images", "all_masks", "first_frame", "first_mask", "last_frame", "last_mask", "custom_frame", "custom_mask")
FUNCTION = "load_gif_image"
@staticmethod
def _resolve_image_path(image: str) -> str:
if isinstance(image, str) and image and os.path.isfile(image):
return image
return folder_paths.get_annotated_filepath(image)
def load_gif_image(self, image, custom_frame):
image_path = self._resolve_image_path(image)
img = node_helpers.pillow(Image.open, image_path)
output_images = []
output_masks = []
w, h = None, None
excluded_formats = ['MPO']
for i in ImageSequence.Iterator(img):
i = node_helpers.pillow(ImageOps.exif_transpose, i)
if i.mode == 'I':
i = i.point(lambda i: i * (1 / 255))
if len(output_images) == 0:
w = i.size[0]
h = i.size[1]
if i.size[0] != w or i.size[1] != h:
continue
# Handle transparency properly - output RGBA when transparency exists
if 'A' in i.getbands():
# Image has alpha channel - use RGBA directly
rgba_image = i.convert("RGBA")
alpha = np.array(rgba_image.getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(alpha)
# Output RGBA image to preserve transparency
image = np.array(rgba_image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
elif i.mode == 'P' and 'transparency' in i.info:
# Palette image with transparency - use RGBA
rgba_image = i.convert('RGBA')
alpha = np.array(rgba_image.getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(alpha)
# Output RGBA image to preserve transparency
image = np.array(rgba_image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
else:
# No transparency - output RGB with zero mask
mask = torch.zeros((h, w), dtype=torch.float32, device="cpu")
rgb_image = i.convert("RGB")
image = np.array(rgb_image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
output_images.append(image)
output_masks.append(mask.unsqueeze(0))
if len(output_images) == 0:
raise ValueError("No valid frames found in the image")
# All images concatenated (like original LoadImage behavior)
if len(output_images) > 1 and img.format not in excluded_formats:
all_images = torch.cat(output_images, dim=0)
all_masks = torch.cat(output_masks, dim=0)
else:
all_images = output_images[0]
all_masks = output_masks[0]
# First frame and mask
first_frame = output_images[0]
first_mask = output_masks[0]
# Last frame and mask
last_frame = output_images[-1]
last_mask = output_masks[-1]
# Custom frame and mask
if custom_frame < len(output_images):
custom_frame_img = output_images[custom_frame]
custom_frame_mask = output_masks[custom_frame]
else:
# If custom_frame is out of range, default to last frame
custom_frame_img = output_images[-1]
custom_frame_mask = output_masks[-1]
return (all_images, all_masks, first_frame, first_mask, last_frame, last_mask, custom_frame_img, custom_frame_mask)
@classmethod
def IS_CHANGED(s, image, custom_frame):
image_path = SmartLoadGIFImage._resolve_image_path(image)
m = hashlib.sha256()
with open(image_path, 'rb') as f:
m.update(f.read())
m.update(str(custom_frame).encode())
return m.digest().hex()
@classmethod
def VALIDATE_INPUTS(s, image, custom_frame):
if not image:
return "Image path is required"
if os.path.isfile(image):
return True
if folder_paths.exists_annotated_filepath(image):
return True
return "Invalid image file: {}".format(image)
class SmartImagePaletteCreate:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"colors": ("INT", {"default": 5, "min": 1, "max": 5, "step": 1}),
"color_1": ("SMART_COLOR", {"default": "#000000"}),
"color_2": ("SMART_COLOR", {"default": "#000000"}),
"color_3": ("SMART_COLOR", {"default": "#000000"}),
"color_4": ("SMART_COLOR", {"default": "#000000"}),
"color_5": ("SMART_COLOR", {"default": "#000000"}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("palette_image",)
FUNCTION = "create_palette"
CATEGORY = "SmartImageTools"
def hex_to_rgb(self, hex_color):
"""Convert hex color string to RGB tuple."""
hex_color = hex_color.lstrip('#')
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
def create_palette(self, colors, color_1, color_2, color_3, color_4, color_5):
"""Create a palette image with specified colors."""
# Collect all color inputs
color_inputs = [color_1, color_2, color_3, color_4, color_5]
# Take only the number of colors specified
selected_colors = color_inputs[:colors]
# Convert hex colors to RGB
rgb_colors = []
for hex_color in selected_colors:
rgb = self.hex_to_rgb(hex_color)
rgb_colors.append(rgb)
# Create palette image: 1 pixel height, colors pixels width
height = 1
width = len(rgb_colors)
# Create numpy array for the image
palette_img = np.zeros((height, width, 3), dtype=np.uint8)
# Fill each pixel with the corresponding color
for i, rgb in enumerate(rgb_colors):
palette_img[0, i] = rgb
# Convert to torch tensor and add batch dimension
palette_tensor = torch.from_numpy(palette_img).float() / 255.0
palette_tensor = palette_tensor.unsqueeze(0) # Add batch dimension
return (palette_tensor,)
class SmartBackgroundFill:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"background_color": (list(BG_COLOR_MAP.keys()), {"default": "white"}),
},
"optional": {
"mask": ("MASK",),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "fill_background"
CATEGORY = "SmartImageTools"
def fill_background(self, image, background_color, mask=None):
# Convert from tensor to numpy array
input_image = 255. * image.cpu().numpy()
batch_size = input_image.shape[0]
output_images = []
# Get background color RGB values (0-255 range)
if background_color == "transparent":
# For transparent, use black with alpha=0, but since we're filling background,
# this doesn't make sense - default to white
bg_rgb = (255, 255, 255)
else:
bg_rgb = BG_COLOR_MAP[background_color]
if bg_rgb is None:
# Fallback to white if color is not found
bg_rgb = (255, 255, 255)
for i in range(batch_size):
img = input_image[i].copy()
channels = img.shape[2] if len(img.shape) > 2 else 0
if channels == 3:
# RGB image - pass through without modification
output_images.append(img)
elif channels == 4:
# RGBA image - process transparency and output RGB
h, w = img.shape[:2]
# Get alpha channel - either from image or from mask
if mask is not None:
# Use mask's alpha values
# Mask: 0.0 = keep original (opaque), 1.0 = fill background (transparent)
# Convert to alpha: 0.0 = transparent, 1.0 = opaque
mask_np = mask[i].cpu().numpy()
alpha = 1.0 - mask_np # Invert mask values for proper alpha interpretation
else:
# Use image's alpha channel (0.0 = transparent, 1.0 = opaque)
alpha = img[:, :, 3] / 255.0
# Create RGB result image (always 3 channels)
result = np.zeros((h, w, 3), dtype=np.uint8)
# For fully transparent pixels (alpha = 0), set to background color
transparent_mask = alpha == 0.0
result[transparent_mask] = bg_rgb
# For semi-transparent pixels, blend with background color
semi_transparent_mask = (alpha > 0.0) & (alpha < 1.0)
if semi_transparent_mask.any():
# Blend: result = (original * alpha) + (background * (1 - alpha))
original_rgb = img[semi_transparent_mask, :3]
alpha_values = alpha[semi_transparent_mask]
# Expand alpha and bg_rgb for broadcasting
alpha_expanded = alpha_values[:, np.newaxis] # Shape: [num_pixels, 1]
bg_rgb_expanded = np.array(bg_rgb)[np.newaxis, :] # Shape: [1, 3]
# Perform blending
blended_rgb = original_rgb * alpha_expanded + bg_rgb_expanded * (1.0 - alpha_expanded)
# Set blended RGB values back
result[semi_transparent_mask] = blended_rgb.astype(np.uint8)
# For fully opaque pixels, copy original RGB
opaque_mask = alpha == 1.0
result[opaque_mask] = img[opaque_mask, :3]
output_images.append(result)
else:
# Unexpected number of channels - pass through
output_images.append(img)
# Convert back to tensor
output_tensor = torch.from_numpy(np.stack(output_images) / 255.0).float()
return (output_tensor,)
class SmartGetMask:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
}
}
RETURN_TYPES = ("MASK",)
FUNCTION = "get_mask"
CATEGORY = "SmartImageTools"
def get_mask(self, image):
# Convert from tensor to numpy array
input_image = image.cpu().numpy()
batch_size = input_image.shape[0]
output_masks = []
for i in range(batch_size):
img = input_image[i]
channels = img.shape[2] if len(img.shape) > 2 else 0
if channels == 3:
# RGB image - create pure white mask (all 1.0) representing opaque areas
h, w = img.shape[:2]
mask = np.ones((h, w), dtype=np.float32)
output_masks.append(mask)
elif channels == 4:
# RGBA image - extract and invert alpha channel as mask
# Alpha channel is 0.0 (transparent) to 1.0 (opaque)
# Invert so transparent areas (0.0) become white (1.0) in mask
# And opaque areas (1.0) become black (0.0) in mask
alpha = img[:, :, 3] # Alpha channel
mask = 1.0 - alpha # Invert the mask
output_masks.append(mask)
else:
# Unexpected number of channels - create white mask as fallback
h, w = img.shape[:2] if len(img.shape) >= 2 else (64, 64) # Default size if unknown
mask = np.ones((h, w), dtype=np.float32)
output_masks.append(mask)
# Convert to tensor with batch dimension [batch, height, width]
output_tensor = torch.from_numpy(np.stack(output_masks))
return (output_tensor,)
class SmartImagePadding:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"left": ("INT", {"default": 0, "min": 0, "max": 10000, "step": 1}),
"right": ("INT", {"default": 0, "min": 0, "max": 10000, "step": 1}),
"top": ("INT", {"default": 0, "min": 0, "max": 10000, "step": 1}),
"bottom": ("INT", {"default": 0, "min": 0, "max": 10000, "step": 1}),
"fill": (["Transparent", "Black", "White", "Grey", "Stretch"], {"default": "Transparent"}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "add_padding"
CATEGORY = "SmartImageTools"
def add_padding(self, image, left, right, top, bottom, fill):
# Convert from tensor to numpy array
input_image = 255. * image.cpu().numpy()
batch_size = input_image.shape[0]
output_images = []
for i in range(batch_size):
img = input_image[i].copy()
h, w = img.shape[:2]
channels = img.shape[2] if len(img.shape) > 2 else 0
# Convert RGB to RGBA if needed
if channels == 3:
# Add alpha channel - fully opaque
alpha_channel = np.full((h, w, 1), 255, dtype=np.uint8)
img = np.concatenate([img, alpha_channel], axis=2)
elif channels != 4:
# Handle unexpected channel count - create RGBA with opaque alpha
rgba_img = np.zeros((h, w, 4), dtype=np.uint8)
if channels >= 3:
rgba_img[:, :, :3] = img[:, :, :3]
else:
# Grayscale or other - replicate to RGB
rgba_img[:, :, :3] = img[:, :, 0] if channels >= 1 else 255
rgba_img[:, :, 3] = 255 # Fully opaque
img = rgba_img
# Calculate new dimensions
new_h = h + top + bottom
new_w = w + left + right
# Create new image with appropriate fill
if fill == "Transparent":
padded_img = np.zeros((new_h, new_w, 4), dtype=np.uint8)
elif fill == "Black":
padded_img = np.zeros((new_h, new_w, 4), dtype=np.uint8)
padded_img[:, :, 3] = 255 # Fully opaque alpha
elif fill == "White":
padded_img = np.full((new_h, new_w, 4), 255, dtype=np.uint8)
elif fill == "Grey":
padded_img = np.full((new_h, new_w, 4), 128, dtype=np.uint8)
padded_img[:, :, 3] = 255 # Fully opaque alpha
elif fill == "Stretch":
padded_img = np.zeros((new_h, new_w, 4), dtype=np.uint8)
# Fill with stretched edge pixels
# Top padding
if top > 0:
for y in range(top):
padded_img[y, left:left + w] = img[0, :]
# Bottom padding
if bottom > 0:
for y in range(top + h, new_h):
padded_img[y, left:left + w] = img[h - 1, :]
# Left padding
if left > 0:
for x in range(left):
padded_img[top:top + h, x] = img[:, 0]
# Right padding
if right > 0:
for x in range(left + w, new_w):
padded_img[top:top + h, x] = img[:, w - 1]
# Fill corners
if top > 0 and left > 0:
padded_img[:top, :left] = img[0, 0]
if top > 0 and right > 0:
padded_img[:top, left + w:] = img[0, w - 1]
if bottom > 0 and left > 0:
padded_img[top + h:, :left] = img[h - 1, 0]
if bottom > 0 and right > 0:
padded_img[top + h:, left + w:] = img[h - 1, w - 1]
# Copy original image to the center (accounting for padding)
padded_img[top:top + h, left:left + w] = img
output_images.append(padded_img)
# Convert back to tensor
output_tensor = torch.from_numpy(np.stack(output_images) / 255.0).float()
return (output_tensor,)
class SmartGradientDeformation:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"type": (["vertical", "horizontal"],),
"start_pos": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"end_pos": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"offset": ("INT", {"default": 20, "min": -8192, "max": 8192, "step": 1}),
"exponent": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 10.0, "step": 0.01}),
}
}
RETURN_TYPES = ("IMAGE", "IMAGE")
RETURN_NAMES = ("image", "gradient")
FUNCTION = "deform"
CATEGORY = "SmartImageTools"
def deform(self, image, type, start_pos, end_pos, offset, exponent):
if start_pos > end_pos:
start_pos, end_pos = end_pos, start_pos
batch_size, height, width, _ = image.shape
deformed_images = []
gradient_images = []
for i in range(batch_size):
img_tensor = image[i]
img_np = img_tensor.cpu().numpy()
if type == "vertical":
size = height
else:
size = width
start = int(start_pos * size)
end = int(end_pos * size)
grad_1d = np.zeros((size,), dtype=np.float32)
if start < end:
ramp = np.linspace(0, 1, num=end - start, dtype=np.float32)
grad_1d[start:end] = offset * (ramp ** exponent)
if end < size:
grad_1d[end:] = offset
map_x, map_y = np.meshgrid(np.arange(width, dtype=np.float32), np.arange(height, dtype=np.float32))
if type == "vertical":
displacement = grad_1d.reshape(-1, 1)
map_y = map_y - displacement
gradient_map = np.tile(displacement, (1, width))
else:
map_x = map_x - grad_1d
gradient_map = np.tile(grad_1d, (height, 1))
deformed_img_np = cv2.remap(img_np, map_x, map_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
min_val, max_val = np.min(gradient_map), np.max(gradient_map)
if max_val == min_val:
gradient_map_norm = np.zeros_like(gradient_map, dtype=np.float32)
else:
gradient_map_norm = (gradient_map - min_val) / (max_val - min_val)
gradient_img_np = np.stack([gradient_map_norm] * 3, axis=-1)
deformed_images.append(torch.from_numpy(deformed_img_np))
gradient_images.append(torch.from_numpy(gradient_img_np.astype(np.float32)))
deformed_batch = torch.stack(deformed_images)
gradient_batch = torch.stack(gradient_images)
return (deformed_batch, gradient_batch)
class SmartColorMatch:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"target": ("IMAGE",),
"reference": ("IMAGE",),
"method": (
[
'mkl',
'hm',
'reinhard',
'mvgd',
'hm-mvgd-hm',
'hm-mkl-hm',
], {
"default": 'mkl'
}),
},
"optional": {
"reference_end": ("IMAGE",),
"strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.01}),
"multithread": ("BOOLEAN", {"default": True}),
}
}
CATEGORY = "SmartImageTools"
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "colormatch"
DESCRIPTION = """
Smart Color Match with progressive reference blending.
If reference_end is not provided, works like standard color matching.
If reference_end is provided with a batch of target images, the reference
gradually transitions from 'reference' to 'reference_end' across the sequence.
Based on color-matcher by hahnec:
https://github.com/hahnec/color-matcher/
"""
def colormatch(self, target, reference, method, reference_end=None, strength=1.0, multithread=True):
try:
from color_matcher import ColorMatcher
except:
raise Exception("Can't import color-matcher, did you install requirements.txt? Manual install: pip install color-matcher")
target = target.cpu()
reference = reference.cpu()
if reference_end is not None:
reference_end = reference_end.cpu()
batch_size = target.size(0)
target_images = target.squeeze()
reference_images = reference.squeeze()
target_np = target_images.numpy()
reference_np = reference_images.numpy()
if reference_end is not None:
reference_end_images = reference_end.squeeze()
reference_end_np = reference_end_images.numpy()
else:
reference_end_np = None
def process(i):
cm = ColorMatcher()
target_np_i = target_np if batch_size == 1 else target_images[i].numpy()
# Store original alpha if present
target_has_alpha = target_np_i.shape[-1] == 4
if target_has_alpha:
target_alpha = target_np_i[:, :, 3].copy()
target_rgb = target_np_i[:, :, :3].copy()
# Fill fully transparent pixels with black
transparent_mask = target_alpha < 0.01
target_rgb[transparent_mask] = 0.0
else:
target_rgb = target_np_i
try:
if reference_end_np is None:
# No blending - single color match
reference_np_i = reference_np if reference.size(0) == 1 else reference_images[i].numpy()
# Handle reference alpha channel
ref_has_alpha = reference_np_i.shape[-1] == 4
if ref_has_alpha:
ref_alpha = reference_np_i[:, :, 3]
ref_rgb = reference_np_i[:, :, :3].copy()
# Fill fully transparent pixels with black
ref_transparent_mask = ref_alpha < 0.01
ref_rgb[ref_transparent_mask] = 0.0
else:
ref_rgb = reference_np_i
# Perform color matching on RGB channels only
image_result = cm.transfer(src=target_rgb, ref=ref_rgb, method=method)
image_result = target_rgb + strength * (image_result - target_rgb)
image_result = np.clip(image_result, 0.0, 1.0)
else:
# Progressive blending - do two color matches and blend results
if batch_size > 1:
progress = i / (batch_size - 1)
else:
progress = 0.0
# Get reference images (use first frame if single frame reference)
ref1 = reference_np if reference.size(0) == 1 else reference_images[i if i < reference.size(0) else 0].numpy()
ref2 = reference_end_np if reference_end.size(0) == 1 else reference_end_images[i if i < reference_end.size(0) else 0].numpy()
# Process ref1
ref1_has_alpha = ref1.shape[-1] == 4
if ref1_has_alpha:
ref1_alpha = ref1[:, :, 3]
ref1_rgb = ref1[:, :, :3].copy()
ref1_transparent_mask = ref1_alpha < 0.01
ref1_rgb[ref1_transparent_mask] = 0.0
else:
ref1_rgb = ref1
# Process ref2
ref2_has_alpha = ref2.shape[-1] == 4
if ref2_has_alpha:
ref2_alpha = ref2[:, :, 3]
ref2_rgb = ref2[:, :, :3].copy()
ref2_transparent_mask = ref2_alpha < 0.01
ref2_rgb[ref2_transparent_mask] = 0.0
else:
ref2_rgb = ref2
# Perform color matching with ref1
result1 = cm.transfer(src=target_rgb, ref=ref1_rgb, method=method)
result1 = target_rgb + strength * (result1 - target_rgb)
result1 = np.clip(result1, 0.0, 1.0)
# Perform color matching with ref2
result2 = cm.transfer(src=target_rgb, ref=ref2_rgb, method=method)
result2 = target_rgb + strength * (result2 - target_rgb)
result2 = np.clip(result2, 0.0, 1.0)
# Blend the two results based on progress
image_result = result1 * (1.0 - progress) + result2 * progress
image_result = np.clip(image_result, 0.0, 1.0)
# If target had alpha, restore it
if target_has_alpha:
result_with_alpha = np.zeros((image_result.shape[0], image_result.shape[1], 4), dtype=np.float32)
result_with_alpha[:, :, :3] = image_result
result_with_alpha[:, :, 3] = target_alpha
return torch.from_numpy(result_with_alpha)
else:
return torch.from_numpy(image_result)
except Exception as e:
print(f"Frame {i} color match error: {e}")
return torch.from_numpy(target_np_i) # fallback
if multithread and batch_size > 1:
max_threads = min(os.cpu_count() or 1, batch_size)
with ThreadPoolExecutor(max_workers=max_threads) as executor:
out = list(executor.map(process, range(batch_size)))
else:
out = [process(i) for i in range(batch_size)]
out = torch.stack(out, dim=0).to(torch.float32)
out.clamp_(0, 1)
return (out,)
class SmartImageCrop:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"measurement": (["pixels", "percent"],),
"left": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}),
"right": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}),
"top": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}),
"bottom": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "crop_image"
CATEGORY = "SmartImageTools"
def crop_image(self, image, measurement, left, right, top, bottom):
batch_size = image.shape[0]
output_images = []
for i in range(batch_size):
img_tensor = image[i]
h, w = img_tensor.shape[:2]
channels = img_tensor.shape[2]
has_alpha = channels == 4
# Convert percent to pixels if needed
if measurement == "percent":
left_px = int(round(w * left / 100.0))
right_px = int(round(w * right / 100.0))
top_px = int(round(h * top / 100.0))
bottom_px = int(round(h * bottom / 100.0))
else: # pixels
left_px = left
right_px = right
top_px = top
bottom_px = bottom
# Calculate new dimensions
new_w = w - left_px - right_px
new_h = h - top_px - bottom_px
# Ensure dimensions are at least 1
new_w = max(1, new_w)
new_h = max(1, new_h)
# Create output image
if has_alpha:
output_img = torch.zeros((new_h, new_w, 4), dtype=img_tensor.dtype, device=img_tensor.device)
else:
output_img = torch.zeros((new_h, new_w, 3), dtype=img_tensor.dtype, device=img_tensor.device)
# Calculate source and destination regions
# Source region in original image
src_top = max(0, top_px)
src_bottom = min(h, h - bottom_px)
src_left = max(0, left_px)
src_right = min(w, w - right_px)
# Destination region in output image
dst_top = max(0, -top_px)
dst_left = max(0, -left_px)
# Calculate how much we can actually copy
copy_h = min(src_bottom - src_top, new_h - dst_top)
copy_w = min(src_right - src_left, new_w - dst_left)
# Ensure we don't copy negative amounts
if copy_h > 0 and copy_w > 0:
# Copy the overlapping region
output_img[dst_top:dst_top+copy_h, dst_left:dst_left+copy_w] = \
img_tensor[src_top:src_top+copy_h, src_left:src_left+copy_w]
output_images.append(output_img)
# Stack all processed images
output_batch = torch.stack(output_images)
return (output_batch,)
class SmartFillTransparentHoles:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"target": ("IMAGE",),
"source": ("IMAGE",),
"min_hole_size": ("INT", {"default": 1, "min": 1, "max": 100000, "step": 1}),
"max_hole_size": ("INT", {"default": 15, "min": 1, "max": 100000, "step": 1}),
"threshold": ("FLOAT", {"default": 0.9, "min": 0.0, "max": 1.0, "step": 0.01}),
"blend": ("BOOLEAN", {"default": True}),
"ignore_left": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}),
"ignore_right": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}),
"ignore_top": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}),
"ignore_bottom": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}),
}
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "filled_mask")
FUNCTION = "fill_holes"
CATEGORY = "SmartImageTools"
def process_single_image(self, target_img, source_img, min_hole_size, max_hole_size, threshold, blend,
ignore_left, ignore_right, ignore_top, ignore_bottom):
"""Process a single image - optimized with scipy connected components."""
from scipy import ndimage
h, w = target_img.shape[:2]
# Resize source if dimensions don't match
sh, sw = source_img.shape[:2]
if sh != h or sw != w:
source_img = cv2.resize(source_img, (w, h), interpolation=cv2.INTER_LINEAR)
# Ensure source has same channel count as target
if source_img.shape[2] != target_img.shape[2]:
if target_img.shape[2] == 4 and source_img.shape[2] == 3:
alpha = np.ones((h, w, 1), dtype=source_img.dtype)
source_img = np.concatenate([source_img, alpha], axis=2)
elif target_img.shape[2] == 3 and source_img.shape[2] == 4:
source_img = source_img[:, :, :3]
# Check if target has alpha channel
if target_img.shape[2] == 4:
# Copy input to output buffer
output_img = target_img.copy()
# Extract transparency mask (alpha channel)
alpha = target_img[:, :, 3]
# Threshold the transparency mask (0 = transparent, 1 = opaque)
binary_mask = (alpha < threshold).astype(np.uint8)
# Create processing area mask (areas not to be ignored)
processing_mask = np.ones((h, w), dtype=bool)
# Apply ignore margins
if ignore_left > 0:
processing_mask[:, :ignore_left] = False
if ignore_right > 0:
processing_mask[:, -ignore_right:] = False
if ignore_top > 0:
processing_mask[:ignore_top, :] = False
if ignore_bottom > 0:
processing_mask[-ignore_bottom:, :] = False
# Create filled area mask
filled_area_mask = np.zeros((h, w), dtype=np.float32)
# Use scipy's label to find connected components (much faster than manual flood fill)
# structure defines 4-connectivity
structure = np.array([[0, 1, 0],
[1, 1, 1],
[0, 1, 0]], dtype=np.uint8)
labeled, num_features = ndimage.label(binary_mask, structure=structure)
if num_features > 0:
# Get properties of each region efficiently
# Calculate sizes
sizes = ndimage.sum(binary_mask, labeled, range(1, num_features + 1))
# Check which regions touch edges
edge_mask = np.zeros((h, w), dtype=bool)
edge_mask[0, :] = True
edge_mask[-1, :] = True
edge_mask[:, 0] = True
edge_mask[:, -1] = True
touches_edge = np.zeros(num_features + 1, dtype=bool)
touches_ignore = np.zeros(num_features + 1, dtype=bool)
for label_id in range(1, num_features + 1):
region_mask = (labeled == label_id)
# Check if region touches image edges
if np.any(region_mask & edge_mask):
touches_edge[label_id] = True
# Check if region overlaps with ignored areas
if np.any(region_mask & ~processing_mask):
touches_ignore[label_id] = True
# Get source data
if source_img.shape[2] == 4:
source_rgb = source_img[:, :, :3]
source_alpha = source_img[:, :, 3]
else:
source_rgb = source_img
source_alpha = np.ones((h, w), dtype=np.float32)
# Process each region
for label_id in range(1, num_features + 1):
area_size = sizes[label_id - 1]
# Skip if touches edge
if touches_edge[label_id]:
continue
# Skip if touches ignored area
if touches_ignore[label_id]:
continue
# Skip if out of size range
if area_size < min_hole_size or area_size > max_hole_size:
continue
# Get mask for this region
fill_mask = (labeled == label_id)
# Copy original alpha to filled area mask
filled_area_mask[fill_mask] = alpha[fill_mask]
# Perform fill/blend in output buffer
if blend:
# Alpha blend mode
target_rgb = target_img[:, :, :3]
target_alpha = alpha
# Expand dimensions for broadcasting
target_alpha_3d = target_alpha[:, :, np.newaxis]
# Blend: result = target * target_alpha + source * (1 - target_alpha)
blended_rgb = target_rgb * target_alpha_3d + source_rgb * (1.0 - target_alpha_3d)
output_img[:, :, :3][fill_mask] = blended_rgb[fill_mask]
# Composite alpha: new_alpha = target_alpha + source_alpha * (1 - target_alpha)
new_alpha = target_alpha + source_alpha * (1.0 - target_alpha)
output_img[:, :, 3][fill_mask] = new_alpha[fill_mask]
else:
# Direct copy mode
output_img[:, :, :3][fill_mask] = source_rgb[fill_mask]
output_img[:, :, 3][fill_mask] = source_alpha[fill_mask]
# Ensure output is in valid range
output_img = np.clip(output_img, 0.0, 1.0)
return output_img, filled_area_mask
else:
# Target has no alpha channel, return as is
target_img = np.clip(target_img, 0.0, 1.0)
return target_img, np.zeros((h, w), dtype=np.float32)
def fill_holes(self, target, source, min_hole_size, max_hole_size, threshold, blend,
ignore_left, ignore_right, ignore_top, ignore_bottom):
# Convert from tensor to numpy array
target_np = target.cpu().numpy()
source_np = source.cpu().numpy()
batch_size = target_np.shape[0]
# Process batch in parallel using ThreadPoolExecutor
def process_batch_item(i):
target_img = target_np[i].copy()
# Get or use first source image
if i < source_np.shape[0]:
source_img = source_np[i].copy()
else:
source_img = source_np[0].copy()
return self.process_single_image(target_img, source_img, min_hole_size, max_hole_size, threshold, blend,
ignore_left, ignore_right, ignore_top, ignore_bottom)
# Use multithreading for batch processing
with ThreadPoolExecutor(max_workers=min(batch_size, os.cpu_count() or 1)) as executor:
results = list(executor.map(process_batch_item, range(batch_size)))
# Separate images and masks
output_images = [result[0] for result in results]
output_masks = [result[1] for result in results]
# Convert back to tensors
output_tensor = torch.from_numpy(np.stack(output_images)).float()
mask_tensor = torch.from_numpy(np.stack(output_masks)).float()
# Ensure values are in 0-1 range
output_tensor = torch.clamp(output_tensor, 0.0, 1.0)
mask_tensor = torch.clamp(mask_tensor, 0.0, 1.0)
return (output_tensor, mask_tensor)
class SmartProgressiveScaleImage:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE",),
"start_scale": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 100.0, "step": 0.01}),
"end_scale": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 100.0, "step": 0.01}),
"scale_method": (["nearest", "bilinear", "bicubic", "lanczos", "area"],),
"curve_exponent": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 10.0, "step": 0.01}),
"end_offset_x": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}),
"end_offset_y": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "progressive_scale"
CATEGORY = "SmartImageTools"
def progressive_scale(self, images, start_scale, end_scale, scale_method, curve_exponent, end_offset_x, end_offset_y):
batch_size = images.shape[0]
# Map scale method names to cv2 interpolation constants
method_map = {
"nearest": cv2.INTER_NEAREST,
"bilinear": cv2.INTER_LINEAR,
"bicubic": cv2.INTER_CUBIC,
"lanczos": cv2.INTER_LANCZOS4,
"area": cv2.INTER_AREA,
}
interp_method = method_map.get(scale_method, cv2.INTER_LINEAR)
scaled_images = []
image_offsets = []
for i in range(batch_size):
# Calculate progress through the batch (0.0 to 1.0)
if batch_size > 1:
progress = i / (batch_size - 1)
else:
progress = 0.0
# Apply curve exponent to progress
curved_progress = progress ** curve_exponent
# Calculate scale factor for this image
scale_factor = start_scale + (end_scale - start_scale) * curved_progress
# Calculate offset for this image (progressively increase from 0 to end_offset)
offset_x = int(round(end_offset_x * curved_progress))
offset_y = int(round(end_offset_y * curved_progress))
image_offsets.append((offset_x, offset_y))
# Get current image
img_tensor = images[i]
img_np = img_tensor.cpu().numpy()
# Convert to uint8 for cv2 processing
img_np = np.clip(img_np, 0.0, 1.0)
img_uint8 = (img_np * 255.0).astype(np.uint8)
h, w = img_uint8.shape[:2]
new_h = max(1, int(round(h * scale_factor)))
new_w = max(1, int(round(w * scale_factor)))
# Resize image
if h == 0 or w == 0:
# Handle empty image case
scaled_img = np.zeros((new_h, new_w, img_uint8.shape[2]), dtype=np.uint8)
else:
scaled_img = cv2.resize(img_uint8, (new_w, new_h), interpolation=interp_method)
# Handle potential shape issues after resize (e.g., grayscale losing channel dim)
if len(scaled_img.shape) == 2 and len(img_uint8.shape) == 3:
scaled_img = np.expand_dims(scaled_img, axis=-1)
if img_uint8.shape[2] > 1:
scaled_img = scaled_img.repeat(img_uint8.shape[2], axis=-1)
# Convert back to float [0, 1]
scaled_img_float = scaled_img.astype(np.float32) / 255.0
scaled_images.append(torch.from_numpy(scaled_img_float))
# Check if we need offset logic or can use simpler logic
if end_offset_x == 0 and end_offset_y == 0:
# No offsets - use original logic: only pad if sizes differ, and center images
shapes = [img.shape for img in scaled_images]
if len(set(shapes)) == 1:
# All images have the same shape, stack normally
output_batch = torch.stack(scaled_images)
else:
# Images have different shapes - pad to max size and center
max_h = max(img.shape[0] for img in scaled_images)
max_w = max(img.shape[1] for img in scaled_images)
channels = scaled_images[0].shape[2]
padded_images = []
for img in scaled_images:
h, w = img.shape[:2]
if h == max_h and w == max_w:
padded_images.append(img)
else:
# Create padded image
padded = torch.zeros((max_h, max_w, channels), dtype=img.dtype)
# Center the image
y_offset = (max_h - h) // 2
x_offset = (max_w - w) // 2
padded[y_offset:y_offset+h, x_offset:x_offset+w] = img
padded_images.append(padded)
output_batch = torch.stack(padded_images)
else:
# Offsets are used - calculate canvas size and position images
max_h = 0
max_w = 0
min_offset_x = 0
min_offset_y = 0
for img, (off_x, off_y) in zip(scaled_images, image_offsets):
h, w = img.shape[:2]
# Track the minimum offset (for negative offsets)
min_offset_x = min(min_offset_x, off_x)
min_offset_y = min(min_offset_y, off_y)
# Calculate maximum canvas size needed
max_w = max(max_w, w + off_x)
max_h = max(max_h, h + off_y)
# Adjust canvas size to account for negative offsets
canvas_w = max_w - min_offset_x
canvas_h = max_h - min_offset_y
channels = scaled_images[0].shape[2]
# Create padded/positioned images on the canvas
positioned_images = []
for img, (off_x, off_y) in zip(scaled_images, image_offsets):
h, w = img.shape[:2]
# Create canvas with transparent/black background
canvas = torch.zeros((canvas_h, canvas_w, channels), dtype=img.dtype)
# Calculate position on canvas (accounting for negative offsets)
y_pos = off_y - min_offset_y
x_pos = off_x - min_offset_x
# Place image on canvas
canvas[y_pos:y_pos+h, x_pos:x_pos+w] = img
positioned_images.append(canvas)
output_batch = torch.stack(positioned_images)
return (output_batch,)
class SmartColorFillMask:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"mask": ("MASK",),
"invert_mask": ("BOOLEAN", {"default": False}),
"color": ("COLOR", {"default": "#FFFFFF"}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "fill_color"
CATEGORY = "SmartImageTools"
def hex_to_rgb(self, hex_color):
"""Convert hex color string to RGB tuple (0-255 range)."""
hex_color = hex_color.lstrip('#')
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
def fill_color(self, image, mask, invert_mask, color):
# Convert from tensor to numpy array
input_image = 255. * image.cpu().numpy()
mask_np = mask.cpu().numpy()
batch_size = input_image.shape[0]
output_images = []
# Convert hex color to RGB
fill_rgb = self.hex_to_rgb(color)
for i in range(batch_size):
img = input_image[i].copy()
h, w = img.shape[:2]
channels = img.shape[2] if len(img.shape) > 2 else 0
# Get mask for this image
if i < mask_np.shape[0]:
current_mask = mask_np[i]
else:
# If batch size mismatch, use last available mask
current_mask = mask_np[-1]
# Invert mask if requested
# Mask: 1.0 = fill with color, 0.0 = keep original
if invert_mask:
current_mask = 1.0 - current_mask
# Ensure mask matches image dimensions
if current_mask.shape[0] != h or current_mask.shape[1] != w:
# Resize mask to match image
current_mask = cv2.resize(current_mask, (w, h), interpolation=cv2.INTER_LINEAR)
# Create mask for broadcasting (h, w, 1)
mask_3d = current_mask[:, :, np.newaxis]
if channels == 3:
# RGB image
result = img.copy()
# Apply color where mask is 1.0
fill_color_array = np.array(fill_rgb, dtype=np.float32)
result = img * (1.0 - mask_3d) + fill_color_array * mask_3d
result = np.clip(result, 0, 255).astype(np.uint8)
output_images.append(result)
elif channels == 4:
# RGBA image
result = img.copy()
# Apply color to RGB channels where mask is 1.0
fill_color_array = np.array(fill_rgb, dtype=np.float32)
result[:, :, :3] = img[:, :, :3] * (1.0 - mask_3d) + fill_color_array * mask_3d
# Keep alpha channel unchanged
result = np.clip(result, 0, 255).astype(np.uint8)
output_images.append(result)
else:
# Unexpected number of channels - pass through
output_images.append(img.astype(np.uint8))
# Convert back to tensor (0-1 range)
output_array = np.stack(output_images) / 255.0
output_tensor = torch.from_numpy(output_array).float()
return (output_tensor,)
class SmartLoadVideo:
"""Load video frames using FFmpeg with correct color space handling.
OpenCV-based loaders (like VHS Load Video Path) apply BT.601 YUV-to-RGB
conversion regardless of the video's actual color matrix metadata. For HD/4K
content encoded with BT.709 this produces a visible red shift and over-saturation.
This node shells out to FFmpeg which reads the container/stream color metadata
and applies the correct conversion matrix automatically.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"video": ("STRING", {"placeholder": "X://insert/path/here.mp4"}),
"force_rate": ("FLOAT", {"default": 0, "min": 0, "max": 60, "step": 0.01}),
"frame_load_cap": ("INT", {"default": 0, "min": 0, "max": 999999, "step": 1}),
"skip_first_frames": ("INT", {"default": 0, "min": 0, "max": 999999, "step": 1}),
"select_every_nth": ("INT", {"default": 1, "min": 1, "max": 999999, "step": 1}),
},
}
RETURN_TYPES = ("IMAGE", "INT", "FLOAT")
RETURN_NAMES = ("images", "frame_count", "fps")
FUNCTION = "load_video"
CATEGORY = "Smart Image Tools"
@staticmethod
def _find_ffmpeg():
path = os.environ.get("VHS_FORCE_FFMPEG_PATH")
if path:
return path
try:
from imageio_ffmpeg import get_ffmpeg_exe
return get_ffmpeg_exe()
except Exception:
pass
import shutil
path = shutil.which("ffmpeg")
if path:
return path
for name in ("ffmpeg", "ffmpeg.exe"):
if os.path.isfile(name):
return os.path.abspath(name)
raise FileNotFoundError(
"ffmpeg is required by SmartLoadVideo and could not be found. "
"Install imageio-ffmpeg, place ffmpeg in the ComfyUI root, "
"or add it to your system PATH."
)
@staticmethod
def _probe_video(ffmpeg, video_path):
import subprocess, re
args = [ffmpeg, "-i", video_path, "-f", "null", "-frames:v", "0", "-"]
res = subprocess.run(args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
info = res.stderr.decode("utf-8", errors="replace")
width = height = 0
fps = 24.0
duration = 0.0
color_space = None
color_range = None
for line in info.split("\n"):
m = re.search(r"Stream .* Video.*, (\d+)x(\d+)", line)
if m:
width, height = int(m.group(1)), int(m.group(2))
fm = re.search(r"([\d.]+)\s*fps", line)
if fm:
fps = float(fm.group(1))
# Parse color info from pix_fmt annotation, e.g. yuv420p(tv, bt709)
cm = re.search(r"\((tv|pc)(?:,\s*(\w+))?\)", line)
if cm:
color_range = cm.group(1)
if cm.group(2):
color_space = cm.group(2)
break
dm = re.search(r"Duration:\s*(\d+):(\d+):([\d.]+)", info)
if dm:
duration = int(dm.group(1)) * 3600 + int(dm.group(2)) * 60 + float(dm.group(3))
if width <= 0 or height <= 0:
raise ValueError(f"Could not parse video dimensions from: {video_path}")
if color_range is None:
color_range = "tv"
if color_space is None:
color_space = "bt709" if min(width, height) >= 720 else "smpte170m"
return width, height, fps, duration, color_space, color_range
def load_video(self, video, force_rate, frame_load_cap, skip_first_frames, select_every_nth):
import subprocess
if not video or not os.path.isfile(video):
raise FileNotFoundError(f"Video file not found: {video!r}")
ffmpeg = self._find_ffmpeg()
width, height, source_fps, duration, color_space, color_range = self._probe_video(ffmpeg, video)
effective_fps = force_rate if force_rate > 0 else source_fps
vfilters = []
if force_rate > 0:
vfilters.append(f"fps=fps={force_rate}")
# Explicit color conversion: use detected (or default) input color matrix
# and range, always output full-range RGB. accurate_rnd and full_chroma_int
# improve the swscale YUV→RGB quality for sub-sampled chroma (yuv420p etc.).
vfilters.append(
f"scale=in_color_matrix={color_space}"
f":in_range={color_range}:out_range=pc"
f":flags=accurate_rnd+full_chroma_int"
)
args = [ffmpeg, "-v", "error", "-an", "-i", video]
args += ["-vf", ",".join(vfilters)]
args += ["-pix_fmt", "rgb24", "-f", "rawvideo", "-"]
bpp = width * height * 3
frames = []
frame_idx = 0
frames_added = 0
with subprocess.Popen(args, stdout=subprocess.PIPE) as proc:
buf = bytearray()
while True:
chunk = proc.stdout.read(bpp - len(buf))
if not chunk:
break
buf.extend(chunk)
if len(buf) < bpp:
continue
raw = bytes(buf)
buf.clear()
frame_idx += 1
if frame_idx <= skip_first_frames:
continue
if (frame_idx - skip_first_frames - 1) % select_every_nth != 0:
continue
frame = np.frombuffer(raw, dtype=np.uint8).reshape(height, width, 3)
frames.append(frame)
frames_added += 1
if frame_load_cap > 0 and frames_added >= frame_load_cap:
proc.terminate()
break
if not frames:
raise RuntimeError(f"No frames decoded from: {video}")
images = np.stack(frames).astype(np.float32) / 255.0
images = torch.from_numpy(images)
output_fps = effective_fps / select_every_nth
return (images, len(images), output_fps)
NODE_CLASS_MAPPINGS = {
"SmartLoadVideo": SmartLoadVideo,
"SmartImagePaletteConvert": SmartImagePaletteConvert,
"SmartImagesProcessor": SmartImagesProcessor,
"SmartGenerateImage": SmartGenerateImage,
"SmartBackgroundRemove": SmartBackgroundRemove,
"SmartPoint": SmartPoint,
"SmartPointSet": SmartPointSet,
"SmartPointSetMerge": SmartPointSetMerge,
"SmartImagePreviewScaled": SmartImagePreviewScaled,
"SmartPreviewPalette": SmartPreviewPalette,
"SmartImagePoint": SmartImagePoint,
"SmartImageRegion": SmartImageRegion,
"SmartImagePaletteExtract": SmartImagePaletteExtract,
"SmartSemiTransparenceRemove": SmartSemiTransparenceRemove,
"SmartVideoPreviewScaled": SmartVideoPreviewScaled,
"SmartVideoPreviewScaledMasked": SmartVideoPreviewScaledMasked,
"SmartSaveAnimatedPNG": SmartSaveAnimatedPNG,
"SmartSavePNG": SmartSavePNG,
"SmartDrawPoints": SmartDrawPoints,
"SmartLoadGIFImage": SmartLoadGIFImage,
"SmartImagePaletteCreate": SmartImagePaletteCreate,
"SmartBackgroundFill": SmartBackgroundFill,
"SmartGetMask": SmartGetMask,
"SmartImagePadding": SmartImagePadding,
"SmartGradientDeformation": SmartGradientDeformation,
"SmartColorMatch": SmartColorMatch,
"SmartImageCrop": SmartImageCrop,
"SmartFillTransparentHoles": SmartFillTransparentHoles,
"SmartProgressiveScaleImage": SmartProgressiveScaleImage,
"SmartColorFillMask": SmartColorFillMask
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SmartLoadVideo": "Smart Load Video",
"SmartImagePaletteConvert": "Smart Image Palette Convert",
"SmartImagesProcessor": "Smart Images Processor",
"SmartGenerateImage": "Smart Generate Image",
"SmartBackgroundRemove": "Smart Remove Background",
"SmartPoint": "Smart Point",
"SmartPointSet": "Smart Point Set",
"SmartPointSetMerge": "Smart Point Set Merge",
"SmartImagePreviewScaled": "Smart Image Preview Scaled",
"SmartPreviewPalette": "Smart Preview Palette",
"SmartImagePoint": "Smart Image Point",
"SmartImageRegion": "Smart Image Region",
"SmartImagePaletteExtract": "Smart Image Palette Extract",
"SmartSemiTransparenceRemove": "Smart Semi-Transparence Remove",
"SmartVideoPreviewScaled": "Smart Video Preview Scaled",
"SmartVideoPreviewScaledMasked": "Smart Video Preview Scaled Masked",
"SmartSaveAnimatedPNG": "Smart Save Animated Image",
"SmartSavePNG": "Smart Save PNG",
"SmartDrawPoints": "Smart Draw Points",
"SmartLoadGIFImage": "Smart Load GIF Image",
"SmartImagePaletteCreate": "Smart Image Palette Create",
"SmartBackgroundFill": "Smart Background Fill",
"SmartGetMask": "Smart Get Mask",
"SmartImagePadding": "Smart Image Padding",
"SmartGradientDeformation": "Smart Gradient Deformation",
"SmartColorMatch": "Smart Color Match",
"SmartImageCrop": "Smart Image Crop",
"SmartFillTransparentHoles": "Smart Fill Transparent Holes",
"SmartProgressiveScaleImage": "Smart Progressive Scale Image",
"SmartColorFillMask": "Smart Color Fill Mask"
}