Files
by-ae-ae-in-workflow/image_selector.py
T
by-aeandCursor Agent e0df0c1614 fix: handle extreme aspect ratios and None input in image_selector
- Fix create_thumbnail crash with extreme aspect ratios (e.g., 1000x1 images)
  by ensuring dimensions are at least 1 pixel before resize
- Add input validation to pil_to_tensor to prevent crash on None input
- Add clear error messages for invalid image inputs

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-10-25 20:58:27 +00:00

867 lines
37 KiB
Python

import os
import pygame
import torch
import numpy as np
from PIL import Image
import math
import time
import json
import threading
from typing import Tuple, List, Optional
def load_images_from_folder(folder_path):
"""Load all image files from folder"""
supported_formats = ('.png', '.jpg', '.jpeg', '.bmp', '.tiff', '.webp')
images = []
paths = []
if not os.path.exists(folder_path):
return images, paths
for file in os.listdir(folder_path):
if file.lower().endswith(supported_formats):
path = os.path.join(folder_path, file)
try:
img = Image.open(path)
images.append(img)
paths.append(path)
except Exception as e:
print(f"Failed to load {path}: {e}")
continue
return images, paths
def create_thumbnail(img, size):
"""Create thumbnail maintaining aspect ratio"""
img_ratio = img.width / img.height
thumb_ratio = size[0] / size[1]
if img_ratio > thumb_ratio:
# Image is wider than thumbnail area
new_width = size[0]
new_height = int(size[0] / img_ratio)
else:
# Image is taller than thumbnail area
new_height = size[1]
new_width = int(size[1] * img_ratio)
# Ensure dimensions are at least 1 pixel (prevent resize errors with extreme aspect ratios)
new_width = max(1, new_width)
new_height = max(1, new_height)
return img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Cache management
def get_cache_dir():
"""Get cache directory using folder_paths API"""
try:
import folder_paths
return os.path.join(folder_paths.get_user_directory(), "ae-in-workflow", "image_selector")
except ImportError:
# Fallback
return os.path.expanduser("~/.comfyui_cache/ae-in-workflow/image_selector")
def load_sort_settings():
"""Load sort settings from cache"""
cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, "settings.json")
try:
if os.path.exists(cache_file):
with open(cache_file, 'r') as f:
data = json.load(f)
return data.get('sort_mode_index', 0), data.get('sort_ascending', True)
except Exception as e:
print(f"Error loading sort settings: {e}")
return 2, False # Default: modified date, descending
def save_sort_settings(sort_mode_index, sort_ascending):
"""Save sort settings to cache"""
cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, "settings.json")
try:
os.makedirs(cache_dir, exist_ok=True)
data = {
'sort_mode_index': sort_mode_index,
'sort_ascending': sort_ascending
}
with open(cache_file, 'w') as f:
json.dump(data, f, indent=2)
except Exception as e:
print(f"Error saving sort settings: {e}")
def render_text_with_outline(font, text, text_color, outline_color=(0, 0, 0), outline_width=1):
"""Render text with black outline for better readability"""
# Render the main text
main_text = font.render(text, True, text_color)
# Create a surface large enough for the text plus outline
width = main_text.get_width() + outline_width * 2
height = main_text.get_height() + outline_width * 2
surface = pygame.Surface((width, height), pygame.SRCALPHA)
# Draw outline by rendering text in black at offset positions
for dx in range(-outline_width, outline_width + 1):
for dy in range(-outline_width, outline_width + 1):
if dx == 0 and dy == 0:
continue # Skip center position
outline_text = font.render(text, True, outline_color)
surface.blit(outline_text, (dx + outline_width, dy + outline_width))
# Draw the main text on top
surface.blit(main_text, (outline_width, outline_width))
return surface
def load_folder_async(folder_path, sort_mode_index, sort_ascending, sort_modes, result_callback):
"""Load folder contents asynchronously"""
def load_task():
try:
# Load images from folder
_, paths = load_images_from_folder(folder_path)
# Sort them
paths = sort_image_paths(paths, sort_modes[sort_mode_index], sort_ascending)
# Call back with results
result_callback(paths)
except Exception as e:
print(f"Error loading folder {folder_path}: {e}")
result_callback([]) # Return empty list on error
thread = threading.Thread(target=load_task, daemon=True)
thread.start()
return thread
def hex_to_rgb(hex_color: str) -> Tuple[int, int, int]:
"""Convert hex color string to RGB tuple
Args:
hex_color: Hex color string (e.g. '#FF0000' or '#F00')
Returns:
Tuple of (red, green, blue) values (0-255)
Raises:
ValueError: If hex_color is not a valid hex color string
"""
if not isinstance(hex_color, str):
raise ValueError(f"hex_color must be a string, got {type(hex_color).__name__}")
if not hex_color.startswith('#'):
raise ValueError(f"hex_color must start with '#', got: {hex_color}")
hex_color = hex_color.lstrip('#')
# Validate length (3 for #RGB or 6 for #RRGGBB)
if len(hex_color) == 3:
# Expand 3-char format: #RGB -> #RRGGBB
hex_color = ''.join(c * 2 for c in hex_color)
elif len(hex_color) != 6:
raise ValueError(f"hex_color must be 3 or 6 characters after '#', got {len(hex_color)}: #{hex_color}")
# Validate hex characters and convert
try:
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
except ValueError as e:
raise ValueError(f"Invalid hex color format: #{hex_color}") from e
def build_folder_tree(root_path, current_path):
"""Build a hierarchical list of folders and files with back navigation"""
tree_items = []
try:
# Add ".." for going up one directory (unless we're at root)
parent_path = os.path.dirname(root_path)
if parent_path != root_path: # Not at filesystem root
tree_items.append(("⬆️ ..", parent_path, True))
# Add current directory
tree_items.append(("📁 " + os.path.basename(root_path), root_path, True))
# Add subdirectories
for item in sorted(os.listdir(root_path)):
full_path = os.path.join(root_path, item)
if os.path.isdir(full_path):
# Highlight current path
prefix = " 📁 "
if full_path == current_path and current_path != root_path:
prefix = "▶️ 📁 "
tree_items.append((prefix + item, full_path, True))
except:
pass
return tree_items
def sort_image_paths(paths, sort_mode, ascending=True):
"""Sort image paths by specified criteria without loading images"""
if not paths:
return paths
# Create pairs of (path, sort_key)
pairs = []
for path in paths:
try:
if sort_mode == 'name':
sort_key = os.path.basename(path).lower()
elif sort_mode == 'size':
sort_key = os.path.getsize(path)
elif sort_mode == 'modified':
sort_key = os.path.getmtime(path)
elif sort_mode == 'created':
sort_key = os.path.getctime(path)
elif sort_mode == 'dimensions':
# For dimensions, we need to load the image temporarily
try:
with Image.open(path) as img:
sort_key = img.width * img.height # Total pixels
except:
sort_key = 0 # Fallback for unreadable images
else:
sort_key = os.path.basename(path).lower() # Default to name
except:
sort_key = os.path.basename(path).lower() # Fallback to name on error
pairs.append((path, sort_key))
# Sort the pairs
reverse = not ascending
if sort_mode in ['size', 'modified', 'created', 'dimensions']:
# Numeric sorting
pairs.sort(key=lambda x: x[1], reverse=reverse)
else:
# String sorting
pairs.sort(key=lambda x: x[1], reverse=reverse)
# Return sorted paths
return [pair[0] for pair in pairs]
def sort_images(images, paths, sort_mode, ascending=True):
"""Sort images and paths by specified criteria (legacy function for compatibility)"""
if not images or not paths:
return images, paths
# Create pairs of (image, path, sort_key)
pairs = []
for img, path in zip(images, paths):
try:
if sort_mode == 'name':
sort_key = os.path.basename(path).lower()
elif sort_mode == 'size':
sort_key = os.path.getsize(path)
elif sort_mode == 'modified':
sort_key = os.path.getmtime(path)
elif sort_mode == 'created':
sort_key = os.path.getctime(path)
elif sort_mode == 'dimensions':
sort_key = img.width * img.height # Total pixels
else:
sort_key = os.path.basename(path).lower() # Default to name
except:
sort_key = os.path.basename(path).lower() # Fallback to name on error
pairs.append((img, path, sort_key))
# Sort the pairs
reverse = not ascending
if sort_mode in ['size', 'modified', 'created', 'dimensions']:
# Numeric sorting
pairs.sort(key=lambda x: x[2], reverse=reverse)
else:
# String sorting
pairs.sort(key=lambda x: x[2], reverse=reverse)
# Unpack back to separate lists
sorted_images = [pair[0] for pair in pairs]
sorted_paths = [pair[1] for pair in pairs]
return sorted_images, sorted_paths
def pil_to_tensor(img):
"""Convert PIL image to torch tensor (1,H,W,3)
Args:
img: PIL Image object
Returns:
tuple: (image_tensor, mask_tensor)
Raises:
ValueError: If img is None or not a PIL Image
"""
if img is None:
raise ValueError("Image cannot be None")
if not hasattr(img, 'mode'):
raise ValueError(f"Expected PIL Image, got {type(img).__name__}")
if img.mode == 'RGBA':
# Handle transparency
img_rgb = img.convert('RGB')
mask = np.array(img)[:, :, 3] # Alpha channel
mask_tensor = torch.from_numpy(mask).float() / 255.0
mask_tensor = mask_tensor.unsqueeze(0) # (1,H,W)
else:
img_rgb = img
# Create all-white mask
mask_tensor = torch.ones(1, img.height, img.width)
# Convert to tensor (H,W,C) format
img_array = np.array(img_rgb)
tensor = torch.from_numpy(img_array).float() / 255.0
tensor = tensor.unsqueeze(0) # (1,H,W,3)
return tensor, mask_tensor
class LazyImageLoader:
"""Lazy loader for images to handle large folders efficiently"""
def __init__(self, paths, thumbnail_size=(200, 200), cache_size=100):
self.paths = paths
self.thumbnail_size = thumbnail_size
self.cache_size = cache_size
self.cache = {} # path -> (thumbnail_surface, last_access_time)
self.access_counter = 0
self.loading_queue = set() # paths currently being loaded
def get_thumbnail(self, path):
"""Get thumbnail for path, loading if necessary"""
if path in self.cache:
# Update access time
self.cache[path] = (self.cache[path][0], self.access_counter)
self.access_counter += 1
return self.cache[path][0]
# Check if already in loading queue
if path in self.loading_queue:
return None # Still loading
# Start loading (limit concurrent loads to prevent choppiness)
if len(self.loading_queue) < 1: # Max 1 concurrent load
self.loading_queue.add(path)
try:
img = Image.open(path)
# Resize more efficiently
img.thumbnail(self.thumbnail_size, Image.Resampling.LANCZOS)
# Create square thumbnail by padding
thumb = Image.new('RGB', self.thumbnail_size, (0, 0, 0))
x = (self.thumbnail_size[0] - img.width) // 2
y = (self.thumbnail_size[1] - img.height) // 2
thumb.paste(img, (x, y))
# Convert PIL to pygame surface
thumb_surface = pygame.image.fromstring(thumb.tobytes(), thumb.size, 'RGB')
# Add to cache
self.cache[path] = (thumb_surface, self.access_counter)
self.access_counter += 1
# Evict old items if cache is full
if len(self.cache) > self.cache_size:
# Find oldest accessed item
oldest_path = min(self.cache.keys(), key=lambda k: self.cache[k][1])
del self.cache[oldest_path]
except Exception as e:
# Create error placeholder
thumb_surface = pygame.Surface(self.thumbnail_size)
thumb_surface.fill((100, 100, 100)) # Gray background
self.cache[path] = (thumb_surface, self.access_counter)
self.access_counter += 1
finally:
self.loading_queue.discard(path)
return self.cache.get(path, (None, 0))[0] if path in self.cache else None
def preload_visible(self, visible_paths):
"""Preload thumbnails for currently visible images"""
for path in visible_paths:
if path not in self.cache and path not in self.loading_queue:
# Start loading (but don't wait)
self.loading_queue.add(path)
# We'll load it when get_thumbnail is called
def create_image_selector_ui(paths, folder_path, padding_color="#000000", target_width_param=None, target_height_param=None, thumbnail_size=(150, 150)):
"""Create pygame UI for image selection with lazy loading"""
pygame.init()
# Screen setup - add space for folder tree
tree_width = 250
screen_width = 1200 + tree_width
screen_height = 800
screen = pygame.display.set_mode((screen_width, screen_height))
pygame.display.set_caption("Image Selector - Click to select, ESC to finish")
# Colors
BG_COLOR = (30, 30, 30)
THUMB_BG = (50, 50, 50)
SELECTED_COLOR = (100, 150, 255)
BORDER_COLOR = (70, 70, 70)
TEXT_COLOR = (220, 220, 220)
TREE_BG = (40, 40, 40)
TREE_SELECTED = (80, 80, 80)
LOADING_COLOR = (80, 80, 120) # Purple-ish for loading
# Font
font = pygame.font.SysFont('Arial', 16)
# Grid setup - adjust for tree panel
cols = 4
padding = 20
thumb_width, thumb_height = thumbnail_size
cell_width = thumb_width + padding * 2
cell_height = thumb_height + padding * 2 + 40 # Extra space for filename
# Tree panel setup
tree_panel_x = 0
tree_panel_y = 0
thumbnail_panel_x = tree_width
thumbnail_panel_y = 0
# Tree navigation state
current_folder = os.path.abspath(folder_path)
folder_tree = build_folder_tree(current_folder, current_folder)
tree_scroll_offset = 0
max_tree_scroll = max(0, len(folder_tree) * 25 - (screen_height - 100))
# Sorting state
sort_modes = ['name', 'size', 'modified', 'created', 'dimensions']
# Load cached sort settings
sort_mode_index, sort_ascending = load_sort_settings()
# Sort paths initially
paths = sort_image_paths(paths, sort_modes[sort_mode_index], sort_ascending)
# Initialize lazy loader instead of loading all thumbnails
lazy_loader = LazyImageLoader(paths, thumbnail_size)
# Loading state for async folder changes
loading_folder = False
loading_thread = None
# Selection state - track selected file paths
selected_paths = [] # Will store paths in order they were selected
selected_path_set = set() # For quick lookup of selected paths
scroll_offset = 0
max_scroll = max(0, ((len(paths) + cols - 1) // cols) * cell_height - screen_height + 100)
running = True
clock = pygame.time.Clock()
select_all = False
# Button position variables (calculated each frame but initialized here to avoid undefined name errors)
select_all_x = 10
sort_mode_x = 150
sort_dir_x = 300
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
running = False
elif event.key == pygame.K_RETURN:
running = False
elif event.key == pygame.K_a and pygame.key.get_mods() & pygame.KMOD_CTRL:
# Ctrl+A to select/deselect all
if len(selected_paths) != len(paths):
# Select all in current sorted order
selected_paths = paths.copy()
selected_path_set = set(paths)
else:
# Deselect all
selected_paths = []
selected_path_set = set()
elif event.key == pygame.K_s and pygame.key.get_mods() & pygame.KMOD_CTRL:
# Ctrl+S to cycle sort mode
sort_mode_index = (sort_mode_index + 1) % len(sort_modes)
save_sort_settings(sort_mode_index, sort_ascending)
paths = sort_image_paths(paths, sort_modes[sort_mode_index], sort_ascending)
# Clear cache and create new lazy loader with sorted paths
lazy_loader = LazyImageLoader(paths, thumbnail_size)
elif event.key == pygame.K_d and pygame.key.get_mods() & pygame.KMOD_CTRL:
# Ctrl+D to toggle sort direction
sort_ascending = not sort_ascending
save_sort_settings(sort_mode_index, sort_ascending)
paths = sort_image_paths(paths, sort_modes[sort_mode_index], sort_ascending)
# Clear cache and create new lazy loader with sorted paths
lazy_loader = LazyImageLoader(paths, thumbnail_size)
elif event.type == pygame.MOUSEBUTTONDOWN:
if event.button == 1: # Left click
mouse_x, mouse_y = pygame.mouse.get_pos()
# Check if click is in tree panel
if mouse_x < tree_width:
# Check if click is on control buttons
button_y = screen_height - 70
if button_y <= mouse_y <= button_y + 20:
# Select All button
select_all_text = "SELECT ALL" if len(selected_paths) != len(paths) else "DESELECT ALL"
select_all_surface = render_text_with_outline(font, select_all_text, TEXT_COLOR)
if select_all_x <= mouse_x <= select_all_x + select_all_surface.get_width():
if len(selected_paths) != len(paths):
# Select all in current sorted order
selected_paths = paths.copy()
selected_path_set = set(paths)
else:
# Deselect all
selected_paths = []
selected_path_set = set()
break
# Sort Mode button
elif sort_mode_x <= mouse_x <= sort_mode_x + font.render(f"Sort: {sort_modes[sort_mode_index].title()}", True, TEXT_COLOR).get_width():
sort_mode_index = (sort_mode_index + 1) % len(sort_modes)
save_sort_settings(sort_mode_index, sort_ascending)
paths = sort_image_paths(paths, sort_modes[sort_mode_index], sort_ascending)
# Clear cache and create new lazy loader with sorted paths
lazy_loader = LazyImageLoader(paths, thumbnail_size)
break
# Sort Direction button
elif sort_dir_x <= mouse_x <= sort_dir_x + font.render("↑" if sort_ascending else "↓", True, TEXT_COLOR).get_width():
sort_ascending = not sort_ascending
save_sort_settings(sort_mode_index, sort_ascending)
paths = sort_image_paths(paths, sort_modes[sort_mode_index], sort_ascending)
# Clear cache and create new lazy loader with sorted paths
lazy_loader = LazyImageLoader(paths, thumbnail_size)
break
else:
# Tree navigation click
tree_y = mouse_y + tree_scroll_offset
item_index = tree_y // 25
if 0 <= item_index < len(folder_tree):
item_name, item_path, is_folder = folder_tree[item_index]
if is_folder:
# Navigate to folder - async loading
current_folder = item_path
loading_folder = True
selected_paths = []
selected_path_set = set()
folder_tree = build_folder_tree(current_folder, current_folder)
tree_scroll_offset = 0
max_tree_scroll = max(0, len(folder_tree) * 25 - (screen_height - 100))
# Start async loading
def on_folder_loaded(new_paths):
nonlocal paths, lazy_loader, max_scroll, loading_folder
paths = new_paths
lazy_loader = LazyImageLoader(paths, thumbnail_size)
max_scroll = max(0, ((len(paths) + cols - 1) // cols) * cell_height - screen_height + 100)
loading_folder = False
loading_thread = load_folder_async(current_folder, sort_mode_index, sort_ascending, sort_modes, on_folder_loaded)
else:
# Thumbnail panel click
mouse_x -= tree_width
mouse_y += scroll_offset
# Check which thumbnail was clicked
for i, path in enumerate(paths):
row = i // cols
col = i % cols
x = thumbnail_panel_x + col * cell_width + padding
y = row * cell_height + padding
if (x <= mouse_x + tree_width < x + thumb_width and
y <= mouse_y < y + thumb_height):
if path in selected_path_set:
# Remove from selection
selected_paths.remove(path)
selected_path_set.remove(path)
else:
# Add to selection
selected_paths.append(path)
selected_path_set.add(path)
break
elif event.type == pygame.MOUSEBUTTONDOWN:
if event.button == 4: # Mouse wheel up
mouse_x, _ = pygame.mouse.get_pos()
if mouse_x < tree_width:
tree_scroll_offset = max(0, min(max_tree_scroll, tree_scroll_offset - 25))
else:
scroll_offset = max(0, min(max_scroll, scroll_offset - 50))
elif event.button == 5: # Mouse wheel down
mouse_x, _ = pygame.mouse.get_pos()
if mouse_x < tree_width:
tree_scroll_offset = max(0, min(max_tree_scroll, tree_scroll_offset + 25))
else:
scroll_offset = max(0, min(max_scroll, scroll_offset + 50))
# Clear screen
screen.fill(BG_COLOR)
# Draw tree panel
pygame.draw.rect(screen, TREE_BG, (0, 0, tree_width, screen_height))
pygame.draw.line(screen, BORDER_COLOR, (tree_width, 0), (tree_width, screen_height), 2)
# Draw tree items
for i, (item_name, item_path, is_folder) in enumerate(folder_tree):
y = i * 25 - tree_scroll_offset
if 0 <= y < screen_height - 50:
if item_path == current_folder:
pygame.draw.rect(screen, TREE_SELECTED, (0, y, tree_width, 25))
text_surface = render_text_with_outline(font, item_name, TEXT_COLOR)
screen.blit(text_surface, (10, y + 5))
# Draw control buttons
button_y = screen_height - 70
# Select All button
select_all_text = "SELECT ALL" if not select_all else "DESELECT ALL"
select_all_surface = font.render(select_all_text, True, TEXT_COLOR)
select_all_x = 10
pygame.draw.rect(screen, THUMB_BG, (select_all_x - 5, button_y - 5, select_all_surface.get_width() + 10, select_all_surface.get_height() + 10))
screen.blit(select_all_surface, (select_all_x, button_y))
# Sort Mode button
sort_mode_text = f"Sort: {sort_modes[sort_mode_index].title()}"
sort_mode_surface = font.render(sort_mode_text, True, TEXT_COLOR)
sort_mode_x = select_all_x + select_all_surface.get_width() + 20
pygame.draw.rect(screen, THUMB_BG, (sort_mode_x - 5, button_y - 5, sort_mode_surface.get_width() + 10, sort_mode_surface.get_height() + 10))
screen.blit(sort_mode_surface, (sort_mode_x, button_y))
# Sort Direction button
sort_dir_text = "↑" if sort_ascending else "↓"
sort_dir_surface = font.render(sort_dir_text, True, TEXT_COLOR)
sort_dir_x = sort_mode_x + sort_mode_surface.get_width() + 20
pygame.draw.rect(screen, THUMB_BG, (sort_dir_x - 5, button_y - 5, sort_dir_surface.get_width() + 10, sort_dir_surface.get_height() + 10))
screen.blit(sort_dir_surface, (sort_dir_x, button_y))
# Draw loading overlay if folder is loading
if loading_folder:
loading_surface = pygame.Surface((screen_width - tree_width, screen_height))
loading_surface.set_alpha(128) # Semi-transparent
loading_surface.fill((0, 0, 0))
screen.blit(loading_surface, (tree_width, 0))
# Loading text in center
loading_text = render_text_with_outline(font, "Loading folder contents...", (255, 255, 255))
text_x = tree_width + (screen_width - tree_width - loading_text.get_width()) // 2
text_y = screen_height // 2 - loading_text.get_height() // 2
screen.blit(loading_text, (text_x, text_y))
else:
# Draw thumbnails (lazy loading) - optimized for smooth scrolling
load_count = 0 # Limit thumbnail loading per frame
max_loads_per_frame = 1 # Only load 1 new thumbnail per frame
for i in range(len(paths)):
row = i // cols
col = i % cols
x = thumbnail_panel_x + col * cell_width + padding
y = row * cell_height + padding - scroll_offset
# Only draw if visible on screen
if y + cell_height < 0 or y > screen_height:
continue
# Get thumbnail (lazy load if needed, but limit loading rate)
path = paths[i]
if path in lazy_loader.cache:
thumb_surface = lazy_loader.cache[path][0]
elif load_count < max_loads_per_frame:
thumb_surface = lazy_loader.get_thumbnail(path)
if thumb_surface: # Successfully loaded
load_count += 1
else:
# Still loading - show placeholder
thumb_surface = pygame.Surface(lazy_loader.thumbnail_size)
thumb_surface.fill((30, 30, 50)) # Dark blue placeholder
font_temp = pygame.font.SysFont('Arial', 12)
text_surface = render_text_with_outline(font_temp, "LOADING...", (255, 255, 255))
thumb_surface.blit(text_surface, (10, lazy_loader.thumbnail_size[1]//2 - 10))
else:
# Loading limit reached - show placeholder
thumb_surface = pygame.Surface(lazy_loader.thumbnail_size)
thumb_surface.fill((20, 20, 40)) # Darker blue placeholder
font_temp = pygame.font.SysFont('Arial', 10)
text_surface = render_text_with_outline(font_temp, "QUEUED", (200, 200, 200))
thumb_surface.blit(text_surface, (5, lazy_loader.thumbnail_size[1]//2 - 8))
# Thumbnail background
bg_color = SELECTED_COLOR if path in selected_path_set else THUMB_BG
pygame.draw.rect(screen, bg_color,
(x - 5, y - 5, thumb_width + 10, thumb_height + 10))
pygame.draw.rect(screen, BORDER_COLOR,
(x - 5, y - 5, thumb_width + 10, thumb_height + 10), 2)
if thumb_surface:
# Thumbnail loaded
screen.blit(thumb_surface, (x, y))
else:
# Still loading - show loading indicator
pygame.draw.rect(screen, LOADING_COLOR, (x, y, thumb_width, thumb_height))
# Only render loading text every few frames to improve performance
if pygame.time.get_ticks() % 500 < 250: # Blink every 500ms
loading_text = render_text_with_outline(font, "LOADING", TEXT_COLOR)
text_rect = loading_text.get_rect(center=(x + thumb_width//2, y + thumb_height//2))
screen.blit(loading_text, text_rect)
# Filename - optimize by caching rendered text
filename = os.path.basename(paths[i])
if len(filename) > 20:
filename = filename[:17] + "..."
text_surface = render_text_with_outline(font, filename, TEXT_COLOR)
text_x = x + (thumb_width - text_surface.get_width()) // 2
text_y = y + thumb_height + 10
screen.blit(text_surface, (text_x, text_y))
# Draw scroll indicator
if max_scroll > 0:
scroll_bar_height = screen_height * (screen_height / (max_scroll + screen_height))
scroll_bar_y = (scroll_offset / max_scroll) * (screen_height - scroll_bar_height)
pygame.draw.rect(screen, BORDER_COLOR, (screen_width - 10, scroll_bar_y, 8, scroll_bar_height))
# Instructions
selected_count = len(selected_paths)
sort_mode_name = sort_modes[sort_mode_index].title()
sort_dir_symbol = "↑" if sort_ascending else "↓"
instr_text = f"Selected: {selected_count} | ESC/ENTER: finish | Ctrl+A: select all | Ctrl+S: sort mode | Ctrl+D: direction"
instr_surface = render_text_with_outline(font, instr_text, TEXT_COLOR)
screen.blit(instr_surface, (tree_width + 10, 10))
# Show current sort status
sort_status_text = f"Sort: {sort_mode_name} {sort_dir_symbol}"
sort_status_surface = font.render(sort_status_text, True, TEXT_COLOR)
screen.blit(sort_status_surface, (tree_width + 10, 35))
pygame.display.flip()
clock.tick(60) # Limit to 60 FPS for smooth scrolling
pygame.quit()
# Return indices of selected images in order they were clicked
return selected_paths
def process_selected_images(images, selected_indices, target_width_param=None, target_height_param=None, padding_color="#000000"):
"""Process selected images into required formats"""
if not selected_indices:
# Return empty tensors if nothing selected
empty_tensor = torch.empty(0, 0, 0, 3)
empty_mask = torch.empty(0, 0, 0)
return [], [], empty_tensor, empty_mask
selected_images = [images[i] for i in selected_indices]
# Convert to tensors
image_list = []
mask_list = []
for img in selected_images:
tensor, mask = pil_to_tensor(img)
image_list.append(tensor)
mask_list.append(mask)
# Determine target dimensions
if target_width_param and target_height_param and target_width_param > 0 and target_height_param > 0:
# Use provided target dimensions
target_width = target_width_param
target_height = target_height_param
else:
# Find largest area image for batch sizing (original logic)
areas = [(img.width * img.height) for img in selected_images]
max_area_idx = areas.index(max(areas))
target_img = selected_images[max_area_idx]
# Calculate target size (max side 2048, maintain aspect ratio)
target_ratio = target_img.width / target_img.height
if target_img.width > target_img.height:
target_width = min(2048, target_img.width)
target_height = int(target_width / target_ratio)
else:
target_height = min(2048, target_img.height)
target_width = int(target_height * target_ratio)
# Resize all images to target size maintaining aspect ratio, pad with black
resized_images = []
resized_masks = []
for tensor, mask in zip(image_list, mask_list):
img = tensor.squeeze(0) # (H,W,3)
mask_2d = mask.squeeze(0) # (H,W)
# Resize image using PIL for better quality with (H,W,C) format
pil_img = Image.fromarray((img.numpy() * 255).astype(np.uint8))
# Calculate scaling to fit within target dimensions while maintaining aspect ratio
img_ratio = pil_img.width / pil_img.height
target_ratio = target_width / target_height
if img_ratio > target_ratio:
# Image is wider than target aspect ratio - fit by width
new_width = target_width
new_height = int(target_width / img_ratio)
else:
# Image is taller than target aspect ratio - fit by height
new_height = target_height
new_width = int(target_height * img_ratio)
# Resize the image maintaining aspect ratio
resized_pil = pil_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Create canvas of target size with specified padding color
try:
rgb_color = hex_to_rgb(padding_color)
except:
rgb_color = (0, 0, 0) # Default to black on error
canvas = Image.new('RGB', (target_width, target_height), rgb_color)
# Paste resized image centered on canvas
x_offset = (target_width - new_width) // 2
y_offset = (target_height - new_height) // 2
canvas.paste(resized_pil, (x_offset, y_offset))
resized_img = torch.from_numpy(np.array(canvas)).float() / 255.0
resized_images.append(resized_img.unsqueeze(0)) # (1,H,W,3)
# Resize mask with same logic
mask_pil = Image.fromarray((mask_2d.numpy() * 255).astype(np.uint8), mode='L')
resized_mask_pil = mask_pil.resize((new_width, new_height), Image.Resampling.NEAREST)
# Create black canvas for mask (0 = transparent for padding)
mask_canvas = Image.new('L', (target_width, target_height), 0)
# Paste resized mask centered on canvas
mask_canvas.paste(resized_mask_pil, (x_offset, y_offset))
resized_mask = torch.from_numpy(np.array(mask_canvas)).float() / 255.0
resized_masks.append(resized_mask.unsqueeze(0)) # (1,H,W)
# Batch tensors
image_batch = torch.cat(resized_images, dim=0) # (B, H, W, 3)
mask_batch = torch.cat(resized_masks, dim=0) # (B, H, W)
return image_list, mask_list, image_batch, mask_batch
def image_selector(folder_path, target_width_param=None, target_height_param=None, padding_color="#000000"):
"""Main function: load image paths, show UI, process selection"""
# Only load paths initially, not images
_, paths = load_images_from_folder(folder_path)
if not paths:
# Return empty results if no images found
empty_tensor = torch.empty(0, 0, 0, 3)
empty_mask = torch.empty(0, 0, 0)
return [], [], empty_tensor, empty_mask
# Sort paths initially (name ascending by default)
paths = sort_image_paths(paths, 'name', True)
# Show selection UI (will load images lazily)
selected_paths = create_image_selector_ui(paths, folder_path, padding_color, target_width_param, target_height_param)
# Now load only the selected images
selected_images = []
for path in selected_paths:
try:
img = Image.open(path)
selected_images.append(img)
except Exception as e:
print(f"Failed to load selected image {path}: {e}")
continue
# Process selected images
return process_selected_images(selected_images, list(range(len(selected_images))), target_width_param, target_height_param, padding_color)
# Module exports for ComfyUI node
__all__ = ['image_selector']