Files
2026-08-27 01:50:00 +02:00

371 lines
15 KiB
Python

import os
import glob
import json
import random
import time
from aiohttp import web
from PIL import Image, ImageOps, ImageSequence # type: ignore
import numpy as np # type: ignore
import torch # type: ignore
import folder_paths # type: ignore
from server import PromptServer # type: ignore
SUPPORTED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tiff", ".avif", ".tga"}
def get_input_directory():
if folder_paths and hasattr(folder_paths, "get_input_directory"):
return folder_paths.get_input_directory()
return os.path.abspath("input")
def resolve_image_path(filename, subfolder=""):
input_dir = get_input_directory()
if subfolder:
clean_sub = subfolder.strip("/\\")
candidate = os.path.join(input_dir, clean_sub, filename)
else:
candidate = os.path.join(input_dir, filename)
if os.path.exists(candidate) and os.path.isfile(candidate):
return candidate
if folder_paths and hasattr(folder_paths, "get_annotated_filepath"):
try:
annotated = folder_paths.get_annotated_filepath(filename)
if os.path.exists(annotated):
return annotated
except Exception:
pass
# Direct fallback if filename is absolute or relative
if os.path.exists(filename) and os.path.isfile(filename):
return os.path.abspath(filename)
return None
def scan_input_images(subfolder=""):
input_dir = get_input_directory()
target_dir = os.path.join(input_dir, subfolder.strip("/\\")) if subfolder else input_dir
if not os.path.exists(target_dir) or not os.path.isdir(target_dir):
return []
results = []
try:
for root, _, files in os.walk(target_dir):
for file in files:
ext = os.path.splitext(file)[1].lower()
if ext in SUPPORTED_EXTENSIONS:
full_path = os.path.join(root, file)
rel_to_input = os.path.relpath(full_path, input_dir).replace("\\", "/")
rel_dir = os.path.dirname(rel_to_input).replace("\\", "/")
file_size = os.path.getsize(full_path)
results.append({
"filename": file,
"subfolder": rel_dir if rel_dir != "." else "",
"relative_path": rel_to_input,
"size": file_size,
"mtime": os.path.getmtime(full_path)
})
except Exception as e:
print(f"[EasyMultiImageLoader] Error scanning folder {target_dir}: {e}")
results.sort(key=lambda x: x["relative_path"].lower())
return results
def create_placeholder_image(text="No Image Loaded"):
# Create a 512x512 dark grey tensor
img_array = np.full((512, 512, 3), 0.15, dtype=np.float32)
# Add a border
img_array[0:8, :, :] = 0.3
img_array[-8:, :, :] = 0.3
img_array[:, 0:8, :] = 0.3
img_array[:, -8:, :] = 0.3
# Add subtle cross lines
for i in range(512):
if 0 <= i < 512:
img_array[i, i, :] = 0.25
img_array[i, 511 - i, :] = 0.25
tensor = torch.from_numpy(img_array)[None,]
mask = torch.zeros((1, 512, 512), dtype=torch.float32)
return tensor, mask
def load_single_image(filepath):
try:
img = Image.open(filepath)
output_images = []
output_masks = []
for frame in ImageSequence.Iterator(img):
frame = ImageOps.exif_transpose(frame)
if frame.mode == 'I':
frame = frame.point(lambda i: i * (1 / 255))
# Mask extraction
if 'A' in frame.getbands():
mask = np.array(frame.getchannel('A')).astype(np.float32) / 255.0
mask = 1.0 - torch.from_numpy(mask)
else:
mask = torch.zeros((frame.height, frame.width), dtype=torch.float32)
rgb_frame = frame.convert("RGB")
np_frame = np.array(rgb_frame).astype(np.float32) / 255.0
tensor_frame = torch.from_numpy(np_frame)[None,]
output_images.append(tensor_frame)
output_masks.append(mask.unsqueeze(0))
if len(output_images) > 1:
out_img = torch.cat(output_images, dim=0)
out_mask = torch.cat(output_masks, dim=0)
else:
out_img = output_images[0]
out_mask = output_masks[0]
return out_img, out_mask
except Exception as e:
print(f"[EasyMultiImageLoader] Error loading image {filepath}: {e}")
return None, None
# ==================== API ENDPOINTS ====================
if PromptServer is not None:
@PromptServer.instance.routes.get("/easy_multi_image/get_input_files")
async def api_get_input_files(request):
try:
subfolder = request.query.get("subfolder", "")
files = scan_input_images(subfolder)
return web.json_response({"files": files, "input_directory": get_input_directory()})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.post("/easy_multi_image/scan_subfolder")
async def api_scan_subfolder(request):
try:
data = await request.json()
subfolder = data.get("subfolder", "")
files = scan_input_images(subfolder)
return web.json_response({"files": files})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# ==================== COMFYUI CUSTOM NODE ====================
def extract_node_id(unique_id):
if isinstance(unique_id, (list, tuple)):
return str(unique_id[0]) if len(unique_id) > 0 else "default_node"
if unique_id is not None:
return str(unique_id)
return "default_node"
class EasyMultiImageLoader:
"""
ComfyUI node to load multiple images from ComfyUI's standard input directory,
manually choose which image to output, or automatically cycle through images with
smooth wrap-around and progress reporting.
"""
# Internal in-memory tracking of cycle steps per node instance
_node_cycle_state = {}
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mode": (["Fixed / Selected Index", "Cycle (Increment)", "Cycle (Decrement)", "Random"], {"default": "Fixed / Selected Index"}),
"index": ("INT", {"default": 1, "min": 1, "max": 999999, "step": 1}),
},
"optional": {
"images_list": ("STRING", {"default": "[]", "multiline": True}),
"subfolder": ("STRING", {"default": "", "multiline": False}),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 64, "step": 1}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
"extra_pnginfo": "EXTRA_PNGINFO",
"prompt": "PROMPT",
}
}
RETURN_TYPES = ("IMAGE", "MASK", "STRING", "INT", "INT", "FLOAT")
RETURN_NAMES = ("IMAGE", "MASK", "filename", "current_index", "total_images", "progress_percent")
FUNCTION = "process_images"
CATEGORY = "🗄️ Comfy Cabinet"
@classmethod
def IS_CHANGED(cls, mode, index, images_list="[]", subfolder="", batch_size=1, unique_id=None, **kwargs):
# If cycling or random, always return unique timestamp so ComfyUI executes on every run in the queue
if mode in ("Cycle (Increment)", "Cycle (Decrement)", "Random"):
return time.time_ns()
# Otherwise hash the static inputs
return f"{mode}_{index}_{images_list}_{subfolder}_{batch_size}"
def process_images(self, mode, index, images_list="[]", subfolder="", batch_size=1, unique_id=None, extra_pnginfo=None, prompt=None):
# Parse images list
parsed_items = []
if images_list and images_list.strip():
try:
raw_json = json.loads(images_list)
if isinstance(raw_json, list):
for item in raw_json:
if isinstance(item, str) and item.strip():
parsed_items.append(item.strip())
elif isinstance(item, dict) and "relative_path" in item:
parsed_items.append(item["relative_path"])
elif isinstance(item, dict) and "filename" in item:
sf = item.get("subfolder", "")
fn = item["filename"]
parsed_items.append(os.path.join(sf, fn).replace("\\", "/") if sf else fn)
except Exception:
# Fallback: line-delimited or comma-delimited strings
for line in images_list.replace("\r", "").split("\n"):
for chunk in line.split(","):
c = chunk.strip().strip('"').strip("'")
if c:
parsed_items.append(c)
# If images_list was empty but a subfolder is specified, auto-scan that subfolder
if not parsed_items and subfolder:
scanned = scan_input_images(subfolder)
parsed_items = [s["relative_path"] for s in scanned]
total_images = len(parsed_items)
if total_images == 0:
print("[EasyMultiImageLoader] Warning: No images found or loaded in the list.")
placeholder_img, placeholder_mask = create_placeholder_image("No Images Loaded")
return {
"ui": {
"index": [0],
"filename": [""],
"progress_percent": [0.0]
},
"result": (placeholder_img, placeholder_mask, "", 0, 0, 0.0)
}
# Determine effective target index (1-based)
node_key = extract_node_id(unique_id)
state = self._node_cycle_state.get(node_key, {})
prev_idx = state.get("index", None)
prev_mode = state.get("mode", None)
if mode == "Fixed / Selected Index":
target_index = ((index - 1) % total_images) + 1
self._node_cycle_state[node_key] = {"index": target_index, "mode": mode}
elif mode == "Cycle (Increment)":
if prev_idx is None or prev_mode != mode:
target_index = ((index - 1) % total_images) + 1
else:
target_index = (prev_idx % total_images) + 1
self._node_cycle_state[node_key] = {"index": target_index, "mode": mode}
elif mode == "Cycle (Decrement)":
if prev_idx is None or prev_mode != mode:
target_index = ((index - 1) % total_images) + 1
else:
target_index = ((prev_idx - 2) % total_images) + 1
self._node_cycle_state[node_key] = {"index": target_index, "mode": mode}
elif mode == "Random":
target_index = random.randint(1, total_images)
self._node_cycle_state[node_key] = {"index": target_index, "mode": mode}
else:
target_index = max(1, min(index, total_images))
self._node_cycle_state[node_key] = {"index": target_index, "mode": mode}
# Calculate progress percentage (100% when at last image)
progress_percent = round((target_index / total_images) * 100.0, 2)
# Collect images for batch
batch_images = []
batch_masks = []
filenames_loaded = []
actual_batch = max(1, min(batch_size, total_images))
ref_h, ref_w = None, None
for b in range(actual_batch):
# Wrap around batch indices if batch_size > 1
curr_b_idx = ((target_index - 1 + b) % total_images) + 1
img_rel_path = parsed_items[curr_b_idx - 1]
full_path = resolve_image_path(img_rel_path, subfolder)
if not full_path:
print(f"[EasyMultiImageLoader] Warning: Could not resolve file path for '{img_rel_path}'")
continue
img_tensor, mask_tensor = load_single_image(full_path)
if img_tensor is None:
continue
# Ensure mask is a valid 3D tensor [B, H, W]
if mask_tensor is None:
mask_tensor = torch.zeros((img_tensor.shape[0], img_tensor.shape[1], img_tensor.shape[2]), dtype=torch.float32)
elif mask_tensor.ndim == 2:
mask_tensor = mask_tensor.unsqueeze(0)
# Ensure all batch items match first image's spatial dimensions (H, W)
if ref_h is None:
ref_h, ref_w = img_tensor.shape[1], img_tensor.shape[2]
else:
if img_tensor.shape[1] != ref_h or img_tensor.shape[2] != ref_w:
# Reshape / interpolate to match reference
img_perm = img_tensor.permute(0, 3, 1, 2) # [B, C, H, W]
img_resized = torch.nn.functional.interpolate(img_perm, size=(ref_h, ref_w), mode="bilinear", align_corners=False)
img_tensor = img_resized.permute(0, 2, 3, 1)
mask_unsqueeze = mask_tensor.unsqueeze(1) # [B, 1, H, W]
mask_resized = torch.nn.functional.interpolate(mask_unsqueeze, size=(ref_h, ref_w), mode="bilinear", align_corners=False)
mask_tensor = mask_resized.squeeze(1)
batch_images.append(img_tensor)
batch_masks.append(mask_tensor)
filenames_loaded.append(os.path.basename(img_rel_path))
if not batch_images:
placeholder_img, placeholder_mask = create_placeholder_image("Failed to Load Image")
return {
"ui": {
"index": [target_index],
"filename": [""],
"progress_percent": [progress_percent]
},
"result": (placeholder_img, placeholder_mask, "", target_index, total_images, progress_percent)
}
final_image = torch.cat(batch_images, dim=0)
final_mask = torch.cat(batch_masks, dim=0)
primary_filename = parsed_items[target_index - 1]
# Send live progress update event to ComfyUI canvas node
if PromptServer is not None:
try:
PromptServer.instance.send_sync("easy_multi_image_progress", {
"node_id": node_key,
"current_index": target_index,
"total_images": total_images,
"progress_percent": progress_percent,
"filename": primary_filename,
"mode": mode
})
except Exception as e:
pass
return {
"ui": {
"index": [target_index],
"filename": [primary_filename],
"progress_percent": [progress_percent]
},
"result": (final_image, final_mask, primary_filename, target_index, total_images, progress_percent)
}
NODE_CLASS_MAPPINGS = {
"EasyMultiImageLoader": EasyMultiImageLoader
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EasyMultiImageLoader": "🖼️ Easy Multi-Image Loader"
}