From 80711e1f997c1fc1650e3cb0a254a780aaf17782 Mon Sep 17 00:00:00 2001 From: Niutonian Date: Tue, 27 Jan 2026 13:39:37 +0100 Subject: [PATCH] Initial commit: Complete Niutonian Smart Image Suite with 7 nodes --- .gitignore | 48 +++++ LICENSE | 21 +++ README.md | 112 +++++++++++ __init__.py | 29 +++ requirements.txt | 8 + smart_batch_processor.py | 381 +++++++++++++++++++++++++++++++++++++ smart_border_frame.py | 351 ++++++++++++++++++++++++++++++++++ smart_collage_maker.py | 399 +++++++++++++++++++++++++++++++++++++++ smart_grid_stitch.py | 313 ++++++++++++++++++++++++++++++ smart_image_splitter.py | 178 +++++++++++++++++ smart_image_stitch.py | 338 +++++++++++++++++++++++++++++++++ smart_panorama_stitch.py | 345 +++++++++++++++++++++++++++++++++ 12 files changed, 2523 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __init__.py create mode 100644 requirements.txt create mode 100644 smart_batch_processor.py create mode 100644 smart_border_frame.py create mode 100644 smart_collage_maker.py create mode 100644 smart_grid_stitch.py create mode 100644 smart_image_splitter.py create mode 100644 smart_image_stitch.py create mode 100644 smart_panorama_stitch.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4c27f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyTorch +*.pth +*.pt + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Templates (user-generated) +templates/ +*.json + +# Temporary files +tmp/ +temp/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..109af20 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Niutonian Smart Image Suite + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..45309ba --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# Niutonian Smart Image Suite + +A comprehensive ComfyUI custom node suite for intelligent image stitching, processing, and manipulation with automatic handling of empty/bypassed inputs. + +## Nodes Included + +### 1. Smart Image Stitch +Linear image stitching with intelligent input handling. +- **Features**: 4-direction stitching, aspect ratio preservation, smart resizing +- **Inputs**: Up to 6 images +- **Use Case**: Creating panoramas, contact sheets, before/after comparisons + +### 2. Smart Grid Stitch +Automatic grid arrangement with flexible sizing. +- **Features**: Auto/manual grid sizing, uniform cell sizing, gap control +- **Inputs**: Up to 9 images +- **Use Case**: Photo grids, comparison matrices, thumbnail sheets + +### 3. Smart Panorama Stitch +Advanced panoramic stitching with feature detection. +- **Features**: Auto overlap detection, feature matching, seamless blending +- **Inputs**: Up to 6 images +- **Use Case**: Landscape panoramas, 360° views, wide-angle compositions + +### 4. Smart Collage Maker +Artistic collage creation with varied layouts. +- **Features**: Multiple layout styles, size variation, rotation, overlapping +- **Inputs**: Up to 9 images +- **Use Case**: Artistic compositions, mood boards, creative layouts + +### 5. Smart Image Splitter +Intelligent image splitting into multiple parts. +- **Features**: Grid splitting, overlap control, multiple output formats +- **Inputs**: Single image +- **Use Case**: Tiling for printing, creating puzzle pieces, detail extraction + +### 6. Smart Border & Frame +Professional framing with multiple styles. +- **Features**: 7 frame styles, shadows, rounded corners, custom colors +- **Inputs**: Single image +- **Use Case**: Photo finishing, presentation, artistic effects + +### 7. Smart Batch Processor +Batch operations with template saving. +- **Features**: Multiple operations, template system, progress logging +- **Inputs**: Up to 9 images +- **Use Case**: Workflow automation, consistent processing, batch jobs + +## Common Features + +- **Smart Input Handling**: Automatically ignores empty or bypassed image inputs +- **Aspect Ratio Preservation**: Multiple handling modes (pad, crop, stretch) +- **High-Quality Resizing**: Lanczos, bilinear, bicubic, nearest neighbor options +- **Flexible Alignment**: Start, center, end positioning +- **Background Control**: Transparent, white, black, custom colors +- **Professional Quality**: Optimized for print and digital use + +## Installation + +Copy this folder to your ComfyUI custom_nodes directory and restart ComfyUI. + +``` +ComfyUI/custom_nodes/comfyui_niutonian_smart_image/ +``` + +All nodes will appear under the "Niutonian/Image Processing" category. + +## Quick Start Examples + +### Basic Linear Stitching +1. Add "Smart Image Stitch" node +2. Connect 2-6 images +3. Set direction (right/left/top/bottom) +4. Choose resize mode and aspect handling +5. Result: Seamlessly stitched image + +### Photo Grid Creation +1. Add "Smart Grid Stitch" node +2. Connect multiple images +3. Select grid size (2x2, 3x3, etc.) +4. Set cell sizing mode +5. Result: Professional photo grid + +### Panorama Creation +1. Add "Smart Panorama Stitch" node +2. Connect overlapping images in sequence +3. Choose auto or manual overlap detection +4. Set feature detector and confidence +5. Result: Seamless panoramic image + +### Artistic Collage +1. Add "Smart Collage Maker" node +2. Connect images for collage +3. Choose layout style (magazine, scattered, spiral) +4. Enable rotation and size variation +5. Result: Creative artistic composition + +## Advanced Usage + +### Template System (Batch Processor) +Save frequently used settings as templates for consistent processing across projects. + +### Chaining Nodes +Combine multiple nodes for complex workflows: +- Split → Process → Grid → Frame +- Stitch → Collage → Border +- Panorama → Split → Batch Process + +### Quality Settings +- Use Lanczos resizing for best quality +- Choose "pad" aspect handling to preserve content +- Enable shadows and borders for professional finish \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..f23f51c --- /dev/null +++ b/__init__.py @@ -0,0 +1,29 @@ +from .smart_image_stitch import NODE_CLASS_MAPPINGS as STITCH_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as STITCH_DISPLAY_MAPPINGS +from .smart_grid_stitch import NODE_CLASS_MAPPINGS as GRID_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as GRID_DISPLAY_MAPPINGS +from .smart_panorama_stitch import NODE_CLASS_MAPPINGS as PANORAMA_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as PANORAMA_DISPLAY_MAPPINGS +from .smart_collage_maker import NODE_CLASS_MAPPINGS as COLLAGE_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as COLLAGE_DISPLAY_MAPPINGS +from .smart_image_splitter import NODE_CLASS_MAPPINGS as SPLITTER_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as SPLITTER_DISPLAY_MAPPINGS +from .smart_border_frame import NODE_CLASS_MAPPINGS as BORDER_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as BORDER_DISPLAY_MAPPINGS +from .smart_batch_processor import NODE_CLASS_MAPPINGS as BATCH_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as BATCH_DISPLAY_MAPPINGS + +# Combine all node mappings +NODE_CLASS_MAPPINGS = {} +NODE_CLASS_MAPPINGS.update(STITCH_MAPPINGS) +NODE_CLASS_MAPPINGS.update(GRID_MAPPINGS) +NODE_CLASS_MAPPINGS.update(PANORAMA_MAPPINGS) +NODE_CLASS_MAPPINGS.update(COLLAGE_MAPPINGS) +NODE_CLASS_MAPPINGS.update(SPLITTER_MAPPINGS) +NODE_CLASS_MAPPINGS.update(BORDER_MAPPINGS) +NODE_CLASS_MAPPINGS.update(BATCH_MAPPINGS) + +# Combine all display name mappings +NODE_DISPLAY_NAME_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS.update(STITCH_DISPLAY_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(GRID_DISPLAY_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(PANORAMA_DISPLAY_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(COLLAGE_DISPLAY_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(SPLITTER_DISPLAY_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(BORDER_DISPLAY_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(BATCH_DISPLAY_MAPPINGS) + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f534ad1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +# Core dependencies (usually provided by ComfyUI) +torch>=1.9.0 +torchvision>=0.10.0 +numpy>=1.21.0 +Pillow>=8.3.0 + +# Optional dependencies for advanced features +opencv-python>=4.5.0 # For panorama stitching feature detection \ No newline at end of file diff --git a/smart_batch_processor.py b/smart_batch_processor.py new file mode 100644 index 0000000..dbd4f8a --- /dev/null +++ b/smart_batch_processor.py @@ -0,0 +1,381 @@ +import torch +import numpy as np +from PIL import Image +import json +import os + +class SmartBatchProcessor: + """ + A ComfyUI node for batch processing multiple image operations with templates. + """ + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "operation_mode": (["stitch_horizontal", "stitch_vertical", "create_grid", "add_borders", "split_images"], {"default": "stitch_horizontal"}), + "batch_size": ("INT", {"default": 4, "min": 1, "max": 9}), + "output_format": (["individual", "combined"], {"default": "individual"}), + "save_template": ("BOOLEAN", {"default": False}), + "template_name": ("STRING", {"default": "my_template"}), + }, + "optional": { + # Stitching parameters + "stitch_direction": (["left", "right", "top", "bottom"], {"default": "right"}), + "stitch_alignment": (["start", "center", "end"], {"default": "center"}), + "resize_mode": (["none", "largest", "smallest"], {"default": "none"}), + "spacing": ("INT", {"default": 10, "min": 0, "max": 100}), + + # Grid parameters + "grid_rows": ("INT", {"default": 2, "min": 1, "max": 4}), + "grid_cols": ("INT", {"default": 2, "min": 1, "max": 4}), + + # Border parameters + "border_width": ("INT", {"default": 20, "min": 0, "max": 100}), + "border_color": (["white", "black", "gray"], {"default": "white"}), + + # Input images + "image_1": ("IMAGE",), + "image_2": ("IMAGE",), + "image_3": ("IMAGE",), + "image_4": ("IMAGE",), + "image_5": ("IMAGE",), + "image_6": ("IMAGE",), + "image_7": ("IMAGE",), + "image_8": ("IMAGE",), + "image_9": ("IMAGE",), + } + } + + RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE", "IMAGE", "STRING") + RETURN_NAMES = ("result_1", "result_2", "result_3", "result_4", "processing_log") + FUNCTION = "process_batch" + CATEGORY = "Niutonian/Image Processing" + + def tensor_to_pil(self, tensor): + """Convert tensor to PIL Image""" + if tensor is None: + return None + + if len(tensor.shape) == 4: + tensor = tensor[0] + + np_image = tensor.cpu().numpy() + + if np_image.dtype == np.float32 or np_image.dtype == np.float64: + np_image = (np_image * 255).astype(np.uint8) + + if len(np_image.shape) == 3: + return Image.fromarray(np_image, 'RGB') + else: + return Image.fromarray(np_image, 'L') + + def pil_to_tensor(self, pil_image): + """Convert PIL Image to tensor""" + if pil_image.mode != 'RGB': + pil_image = pil_image.convert('RGB') + + np_image = np.array(pil_image).astype(np.float32) / 255.0 + tensor = torch.from_numpy(np_image).unsqueeze(0) + return tensor + + def get_valid_images(self, **kwargs): + """Extract valid images from inputs""" + valid_images = [] + + for i in range(1, 10): # image_1 through image_9 + image_key = f"image_{i}" + if image_key in kwargs and kwargs[image_key] is not None: + pil_img = self.tensor_to_pil(kwargs[image_key]) + if pil_img is not None: + valid_images.append(pil_img) + + return valid_images + + def create_empty_tensor(self): + """Create empty tensor for unused outputs""" + empty_img = Image.new('RGB', (64, 64), (0, 0, 0)) + return self.pil_to_tensor(empty_img) + + def get_border_color(self, color_name): + """Get border color from name""" + colors = { + "white": (255, 255, 255), + "black": (0, 0, 0), + "gray": (128, 128, 128), + } + return colors.get(color_name, (255, 255, 255)) + + def resize_image_to_match(self, images, resize_mode): + """Resize images based on mode""" + if not images or resize_mode == "none": + return images + + sizes = [(img.width, img.height) for img in images] + + if resize_mode == "largest": + target_width = max(size[0] for size in sizes) + target_height = max(size[1] for size in sizes) + elif resize_mode == "smallest": + target_width = min(size[0] for size in sizes) + target_height = min(size[1] for size in sizes) + else: + return images + + resized_images = [] + for img in images: + if img.size != (target_width, target_height): + resized_img = img.resize((target_width, target_height), Image.LANCZOS) + resized_images.append(resized_img) + else: + resized_images.append(img) + + return resized_images + + def stitch_images_horizontal(self, images, alignment, spacing): + """Stitch images horizontally""" + if not images: + return None + + if len(images) == 1: + return images[0] + + # Calculate canvas size + total_width = sum(img.width for img in images) + spacing * (len(images) - 1) + max_height = max(img.height for img in images) + + # Create canvas + canvas = Image.new('RGB', (total_width, max_height), (255, 255, 255)) + + # Place images + current_x = 0 + for img in images: + if alignment == "start": + y = 0 + elif alignment == "center": + y = (max_height - img.height) // 2 + else: # end + y = max_height - img.height + + canvas.paste(img, (current_x, y)) + current_x += img.width + spacing + + return canvas + + def stitch_images_vertical(self, images, alignment, spacing): + """Stitch images vertically""" + if not images: + return None + + if len(images) == 1: + return images[0] + + # Calculate canvas size + max_width = max(img.width for img in images) + total_height = sum(img.height for img in images) + spacing * (len(images) - 1) + + # Create canvas + canvas = Image.new('RGB', (max_width, total_height), (255, 255, 255)) + + # Place images + current_y = 0 + for img in images: + if alignment == "start": + x = 0 + elif alignment == "center": + x = (max_width - img.width) // 2 + else: # end + x = max_width - img.width + + canvas.paste(img, (x, current_y)) + current_y += img.height + spacing + + return canvas + + def create_grid_from_images(self, images, rows, cols, spacing): + """Create grid from images""" + if not images: + return None + + # Pad images list to fill grid + while len(images) < rows * cols: + images.append(images[-1] if images else Image.new('RGB', (100, 100), (128, 128, 128))) + + # Calculate cell size (use first image as reference) + cell_width = images[0].width + cell_height = images[0].height + + # Calculate canvas size + canvas_width = cols * cell_width + (cols - 1) * spacing + canvas_height = rows * cell_height + (rows - 1) * spacing + + # Create canvas + canvas = Image.new('RGB', (canvas_width, canvas_height), (255, 255, 255)) + + # Place images in grid + img_index = 0 + for row in range(rows): + for col in range(cols): + if img_index >= len(images): + break + + x = col * (cell_width + spacing) + y = row * (cell_height + spacing) + + # Resize image to cell size if needed + img = images[img_index] + if img.size != (cell_width, cell_height): + img = img.resize((cell_width, cell_height), Image.LANCZOS) + + canvas.paste(img, (x, y)) + img_index += 1 + + return canvas + + def add_border_to_image(self, image, border_width, border_color): + """Add border to single image""" + if border_width == 0: + return image + + new_width = image.width + 2 * border_width + new_height = image.height + 2 * border_width + + bordered = Image.new('RGB', (new_width, new_height), border_color) + bordered.paste(image, (border_width, border_width)) + + return bordered + + def split_image_into_parts(self, image, rows, cols): + """Split image into grid parts""" + cell_width = image.width // cols + cell_height = image.height // rows + + parts = [] + for row in range(rows): + for col in range(cols): + x = col * cell_width + y = row * cell_height + + part = image.crop((x, y, x + cell_width, y + cell_height)) + parts.append(part) + + return parts + + def save_template_config(self, template_name, config): + """Save processing template configuration""" + try: + # Create templates directory if it doesn't exist + templates_dir = os.path.join(os.path.dirname(__file__), "templates") + os.makedirs(templates_dir, exist_ok=True) + + # Save template + template_path = os.path.join(templates_dir, f"{template_name}.json") + with open(template_path, 'w') as f: + json.dump(config, f, indent=2) + + return f"Template saved: {template_path}" + except Exception as e: + return f"Failed to save template: {str(e)}" + + def process_batch(self, operation_mode, batch_size, output_format, save_template, template_name, + stitch_direction="right", stitch_alignment="center", resize_mode="none", spacing=10, + grid_rows=2, grid_cols=2, border_width=20, border_color="white", **kwargs): + """Main batch processing function""" + + # Get valid images + valid_images = self.get_valid_images(**kwargs) + + if not valid_images: + empty = self.create_empty_tensor() + return (empty, empty, empty, empty, "No valid images provided") + + processing_log = [] + results = [] + + try: + if operation_mode == "stitch_horizontal": + # Resize if needed + if resize_mode != "none": + valid_images = self.resize_image_to_match(valid_images, resize_mode) + + # Stitch horizontally + result = self.stitch_images_horizontal(valid_images, stitch_alignment, spacing) + if result: + results.append(result) + processing_log.append(f"Stitched {len(valid_images)} images horizontally") + + elif operation_mode == "stitch_vertical": + # Resize if needed + if resize_mode != "none": + valid_images = self.resize_image_to_match(valid_images, resize_mode) + + # Stitch vertically + result = self.stitch_images_vertical(valid_images, stitch_alignment, spacing) + if result: + results.append(result) + processing_log.append(f"Stitched {len(valid_images)} images vertically") + + elif operation_mode == "create_grid": + # Create grid + result = self.create_grid_from_images(valid_images, grid_rows, grid_cols, spacing) + if result: + results.append(result) + processing_log.append(f"Created {grid_rows}x{grid_cols} grid from {len(valid_images)} images") + + elif operation_mode == "add_borders": + # Add borders to each image + border_color_rgb = self.get_border_color(border_color) + for i, img in enumerate(valid_images): + bordered = self.add_border_to_image(img, border_width, border_color_rgb) + results.append(bordered) + processing_log.append(f"Added border to image {i+1}") + + elif operation_mode == "split_images": + # Split first image into parts + if valid_images: + parts = self.split_image_into_parts(valid_images[0], grid_rows, grid_cols) + results.extend(parts[:4]) # Limit to 4 results + processing_log.append(f"Split image into {len(parts)} parts") + + # Save template if requested + if save_template: + config = { + "operation_mode": operation_mode, + "stitch_direction": stitch_direction, + "stitch_alignment": stitch_alignment, + "resize_mode": resize_mode, + "spacing": spacing, + "grid_rows": grid_rows, + "grid_cols": grid_cols, + "border_width": border_width, + "border_color": border_color, + } + save_result = self.save_template_config(template_name, config) + processing_log.append(save_result) + + except Exception as e: + processing_log.append(f"Error during processing: {str(e)}") + + # Convert results to tensors + result_tensors = [] + for i in range(4): + if i < len(results): + result_tensors.append(self.pil_to_tensor(results[i])) + else: + result_tensors.append(self.create_empty_tensor()) + + log_text = "\n".join(processing_log) + + return (*result_tensors, log_text) + +# Node registration +NODE_CLASS_MAPPINGS = { + "SmartBatchProcessor": SmartBatchProcessor +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SmartBatchProcessor": "Smart Batch Processor" +} \ No newline at end of file diff --git a/smart_border_frame.py b/smart_border_frame.py new file mode 100644 index 0000000..dbd9f62 --- /dev/null +++ b/smart_border_frame.py @@ -0,0 +1,351 @@ +import torch +import numpy as np +from PIL import Image, ImageDraw, ImageFilter +import math + +class SmartBorderFrame: + """ + A ComfyUI node for adding intelligent borders and frames to images. + """ + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "input_image": ("IMAGE",), + "frame_style": (["none", "simple", "classic", "modern", "vintage", "polaroid", "film"], {"default": "simple"}), + "border_width": ("INT", {"default": 20, "min": 0, "max": 200, "step": 2}), + "border_color": (["white", "black", "gray", "custom"], {"default": "white"}), + "corner_style": (["square", "rounded", "beveled"], {"default": "square"}), + "shadow_enabled": ("BOOLEAN", {"default": False}), + "shadow_blur": ("INT", {"default": 10, "min": 0, "max": 50, "step": 1}), + "shadow_offset": ("INT", {"default": 5, "min": 0, "max": 30, "step": 1}), + "shadow_opacity": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.1}), + }, + "optional": { + "custom_color_r": ("INT", {"default": 128, "min": 0, "max": 255}), + "custom_color_g": ("INT", {"default": 128, "min": 0, "max": 255}), + "custom_color_b": ("INT", {"default": 128, "min": 0, "max": 255}), + "corner_radius": ("INT", {"default": 10, "min": 0, "max": 50, "step": 1}), + "inner_border_width": ("INT", {"default": 5, "min": 0, "max": 50, "step": 1}), + "inner_border_color": (["white", "black", "gold", "silver"], {"default": "gold"}), + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("framed_image",) + FUNCTION = "add_frame" + CATEGORY = "Niutonian/Image Processing" + + def tensor_to_pil(self, tensor): + """Convert tensor to PIL Image""" + if tensor is None: + return None + + if len(tensor.shape) == 4: + tensor = tensor[0] + + np_image = tensor.cpu().numpy() + + if np_image.dtype == np.float32 or np_image.dtype == np.float64: + np_image = (np_image * 255).astype(np.uint8) + + if len(np_image.shape) == 3: + return Image.fromarray(np_image, 'RGB') + else: + return Image.fromarray(np_image, 'L') + + def pil_to_tensor(self, pil_image): + """Convert PIL Image to tensor""" + if pil_image.mode != 'RGB': + pil_image = pil_image.convert('RGB') + + np_image = np.array(pil_image).astype(np.float32) / 255.0 + tensor = torch.from_numpy(np_image).unsqueeze(0) + return tensor + + def get_border_color(self, color_name, custom_r=128, custom_g=128, custom_b=128): + """Get border color from name or custom values""" + colors = { + "white": (255, 255, 255), + "black": (0, 0, 0), + "gray": (128, 128, 128), + "gold": (255, 215, 0), + "silver": (192, 192, 192), + } + + if color_name == "custom": + return (custom_r, custom_g, custom_b) + + return colors.get(color_name, (255, 255, 255)) + + def create_shadow(self, image, blur, offset, opacity): + """Create drop shadow for image""" + # Create shadow image + shadow = Image.new('RGBA', + (image.width + offset * 2, image.height + offset * 2), + (0, 0, 0, 0)) + + # Create shadow shape + shadow_color = (0, 0, 0, int(255 * opacity)) + shadow_img = Image.new('RGBA', image.size, shadow_color) + + # Paste shadow at offset position + shadow.paste(shadow_img, (offset, offset)) + + # Apply blur + if blur > 0: + shadow = shadow.filter(ImageFilter.GaussianBlur(radius=blur)) + + return shadow + + def add_rounded_corners(self, image, radius): + """Add rounded corners to image""" + # Create mask for rounded corners + mask = Image.new('L', image.size, 0) + draw = ImageDraw.Draw(mask) + + # Draw rounded rectangle + draw.rounded_rectangle([0, 0, image.width, image.height], radius, fill=255) + + # Apply mask + if image.mode != 'RGBA': + image = image.convert('RGBA') + + # Create output with transparency + output = Image.new('RGBA', image.size, (0, 0, 0, 0)) + output.paste(image, mask=mask) + + return output + + def add_beveled_corners(self, image, bevel_size=10): + """Add beveled corners to image""" + if image.mode != 'RGBA': + image = image.convert('RGBA') + + # Create mask for beveled corners + mask = Image.new('L', image.size, 255) + draw = ImageDraw.Draw(mask) + + # Draw beveled corners (cut off corners) + w, h = image.size + + # Top-left corner + draw.polygon([(0, 0), (bevel_size, 0), (0, bevel_size)], fill=0) + # Top-right corner + draw.polygon([(w, 0), (w - bevel_size, 0), (w, bevel_size)], fill=0) + # Bottom-left corner + draw.polygon([(0, h), (bevel_size, h), (0, h - bevel_size)], fill=0) + # Bottom-right corner + draw.polygon([(w, h), (w - bevel_size, h), (w, h - bevel_size)], fill=0) + + # Apply mask + output = Image.new('RGBA', image.size, (0, 0, 0, 0)) + output.paste(image, mask=mask) + + return output + + def create_simple_frame(self, image, border_width, border_color): + """Create simple border frame""" + if border_width == 0: + return image + + # Create new image with border + new_width = image.width + 2 * border_width + new_height = image.height + 2 * border_width + + framed = Image.new('RGB', (new_width, new_height), border_color) + framed.paste(image, (border_width, border_width)) + + return framed + + def create_classic_frame(self, image, border_width, border_color, inner_width, inner_color): + """Create classic double-border frame""" + if border_width == 0: + return image + + # Create outer border + outer_width = image.width + 2 * border_width + outer_height = image.height + 2 * border_width + + framed = Image.new('RGB', (outer_width, outer_height), border_color) + + # Add inner border if specified + if inner_width > 0 and inner_width < border_width: + inner_x = border_width - inner_width + inner_y = border_width - inner_width + inner_w = image.width + 2 * inner_width + inner_h = image.height + 2 * inner_width + + # Draw inner border + draw = ImageDraw.Draw(framed) + draw.rectangle([inner_x, inner_y, inner_x + inner_w, inner_y + inner_h], + fill=inner_color) + + # Paste original image + framed.paste(image, (border_width, border_width)) + + return framed + + def create_modern_frame(self, image, border_width, border_color): + """Create modern minimalist frame""" + if border_width == 0: + return image + + # Create asymmetric modern border (thicker on bottom) + top_border = border_width // 2 + side_border = border_width // 2 + bottom_border = border_width * 2 + + new_width = image.width + 2 * side_border + new_height = image.height + top_border + bottom_border + + framed = Image.new('RGB', (new_width, new_height), border_color) + framed.paste(image, (side_border, top_border)) + + return framed + + def create_vintage_frame(self, image, border_width, border_color): + """Create vintage-style frame with texture""" + if border_width == 0: + return image + + # Create base frame + framed = self.create_classic_frame(image, border_width, border_color, + border_width // 4, (139, 69, 19)) # Brown inner + + # Add vintage texture effect + draw = ImageDraw.Draw(framed) + + # Add some decorative elements in corners + corner_size = border_width // 2 + + # Simple corner decorations + for x, y in [(corner_size//2, corner_size//2), + (framed.width - corner_size//2, corner_size//2), + (corner_size//2, framed.height - corner_size//2), + (framed.width - corner_size//2, framed.height - corner_size//2)]: + draw.ellipse([x-3, y-3, x+3, y+3], fill=(255, 215, 0)) # Gold dots + + return framed + + def create_polaroid_frame(self, image, border_width): + """Create Polaroid-style frame""" + # Polaroid has white border with extra space at bottom + top_border = border_width + side_border = border_width + bottom_border = border_width * 3 # Extra space for "photo" effect + + new_width = image.width + 2 * side_border + new_height = image.height + top_border + bottom_border + + framed = Image.new('RGB', (new_width, new_height), (255, 255, 255)) + framed.paste(image, (side_border, top_border)) + + return framed + + def create_film_frame(self, image, border_width): + """Create film strip style frame""" + # Film frame with perforations + new_width = image.width + 2 * border_width + new_height = image.height + 2 * border_width + + framed = Image.new('RGB', (new_width, new_height), (0, 0, 0)) + framed.paste(image, (border_width, border_width)) + + # Add film perforations + draw = ImageDraw.Draw(framed) + perf_size = border_width // 4 + + # Top and bottom perforations + for x in range(perf_size, new_width - perf_size, perf_size * 2): + # Top perforations + draw.rectangle([x, perf_size//2, x + perf_size, perf_size + perf_size//2], + fill=(64, 64, 64)) + # Bottom perforations + draw.rectangle([x, new_height - perf_size - perf_size//2, + x + perf_size, new_height - perf_size//2], + fill=(64, 64, 64)) + + return framed + + def add_frame(self, input_image, frame_style, border_width, border_color, corner_style, + shadow_enabled, shadow_blur, shadow_offset, shadow_opacity, + custom_color_r=128, custom_color_g=128, custom_color_b=128, + corner_radius=10, inner_border_width=5, inner_border_color="gold"): + """Main function to add frame to image""" + + # Convert input to PIL + pil_image = self.tensor_to_pil(input_image) + + if pil_image is None: + default_img = Image.new('RGB', (512, 512), (128, 128, 128)) + return (self.pil_to_tensor(default_img),) + + # Get colors + main_color = self.get_border_color(border_color, custom_color_r, custom_color_g, custom_color_b) + inner_color = self.get_border_color(inner_border_color) + + # Apply frame style + if frame_style == "none": + framed = pil_image + elif frame_style == "simple": + framed = self.create_simple_frame(pil_image, border_width, main_color) + elif frame_style == "classic": + framed = self.create_classic_frame(pil_image, border_width, main_color, + inner_border_width, inner_color) + elif frame_style == "modern": + framed = self.create_modern_frame(pil_image, border_width, main_color) + elif frame_style == "vintage": + framed = self.create_vintage_frame(pil_image, border_width, main_color) + elif frame_style == "polaroid": + framed = self.create_polaroid_frame(pil_image, border_width) + elif frame_style == "film": + framed = self.create_film_frame(pil_image, border_width) + else: + framed = self.create_simple_frame(pil_image, border_width, main_color) + + # Apply corner style + if corner_style == "rounded": + framed = self.add_rounded_corners(framed, corner_radius) + elif corner_style == "beveled": + framed = self.add_beveled_corners(framed, corner_radius) + + # Add shadow if enabled + if shadow_enabled: + shadow = self.create_shadow(framed, shadow_blur, shadow_offset, shadow_opacity) + + # Create final image with shadow + final_width = shadow.width + final_height = shadow.height + final_image = Image.new('RGBA', (final_width, final_height), (255, 255, 255, 0)) + + # Paste shadow first + final_image.paste(shadow, (0, 0), shadow) + + # Paste framed image on top + if framed.mode != 'RGBA': + framed = framed.convert('RGBA') + final_image.paste(framed, (0, 0), framed) + + # Convert back to RGB + if final_image.mode == 'RGBA': + rgb_final = Image.new('RGB', final_image.size, (255, 255, 255)) + rgb_final.paste(final_image, mask=final_image.split()[-1]) + framed = rgb_final + else: + framed = final_image + + return (self.pil_to_tensor(framed),) + +# Node registration +NODE_CLASS_MAPPINGS = { + "SmartBorderFrame": SmartBorderFrame +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SmartBorderFrame": "Smart Border & Frame" +} \ No newline at end of file diff --git a/smart_collage_maker.py b/smart_collage_maker.py new file mode 100644 index 0000000..a29b146 --- /dev/null +++ b/smart_collage_maker.py @@ -0,0 +1,399 @@ +import torch +import numpy as np +from PIL import Image, ImageDraw +import random +import math + +class SmartCollageMaker: + """ + A ComfyUI node for creating artistic collages with varied image sizes and layouts. + """ + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "layout_style": (["magazine", "photo_wall", "mosaic", "scattered", "spiral", "custom"], {"default": "magazine"}), + "canvas_width": ("INT", {"default": 1024, "min": 512, "max": 4096, "step": 64}), + "canvas_height": ("INT", {"default": 1024, "min": 512, "max": 4096, "step": 64}), + "size_variation": (["none", "small", "medium", "large", "random"], {"default": "medium"}), + "rotation_enabled": ("BOOLEAN", {"default": False}), + "max_rotation": ("INT", {"default": 15, "min": 0, "max": 45, "step": 5}), + "overlap_allowed": ("BOOLEAN", {"default": False}), + "background_color": (["white", "black", "transparent", "random"], {"default": "white"}), + "border_style": (["none", "thin", "thick", "shadow"], {"default": "none"}), + "spacing": ("INT", {"default": 10, "min": 0, "max": 50, "step": 2}), + }, + "optional": { + "seed": ("INT", {"default": 42, "min": 0, "max": 999999}), + "image_1": ("IMAGE",), + "image_2": ("IMAGE",), + "image_3": ("IMAGE",), + "image_4": ("IMAGE",), + "image_5": ("IMAGE",), + "image_6": ("IMAGE",), + "image_7": ("IMAGE",), + "image_8": ("IMAGE",), + "image_9": ("IMAGE",), + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("collage_image",) + FUNCTION = "create_collage" + CATEGORY = "Niutonian/Image Processing" + + def tensor_to_pil(self, tensor): + """Convert tensor to PIL Image""" + if tensor is None: + return None + + if len(tensor.shape) == 4: + tensor = tensor[0] + + np_image = tensor.cpu().numpy() + + if np_image.dtype == np.float32 or np_image.dtype == np.float64: + np_image = (np_image * 255).astype(np.uint8) + + if len(np_image.shape) == 3: + return Image.fromarray(np_image, 'RGB') + else: + return Image.fromarray(np_image, 'L') + + def pil_to_tensor(self, pil_image): + """Convert PIL Image to tensor""" + if pil_image.mode != 'RGB': + pil_image = pil_image.convert('RGB') + + np_image = np.array(pil_image).astype(np.float32) / 255.0 + tensor = torch.from_numpy(np_image).unsqueeze(0) + return tensor + + def get_valid_images(self, **kwargs): + """Extract valid images from inputs""" + valid_images = [] + + for i in range(1, 10): # image_1 through image_9 + image_key = f"image_{i}" + if image_key in kwargs and kwargs[image_key] is not None: + pil_img = self.tensor_to_pil(kwargs[image_key]) + if pil_img is not None: + valid_images.append(pil_img) + + return valid_images + + def calculate_size_variations(self, images, canvas_size, variation_level): + """Calculate varied sizes for images based on variation level""" + canvas_width, canvas_height = canvas_size + base_area = (canvas_width * canvas_height) / len(images) + base_size = int(math.sqrt(base_area)) + + sizes = [] + + if variation_level == "none": + # All same size + for _ in images: + sizes.append((base_size, base_size)) + + elif variation_level == "small": + # 20% variation + for i, img in enumerate(images): + factor = 0.9 + (i % 3) * 0.1 # 0.9, 1.0, 1.1 + new_size = int(base_size * factor) + sizes.append((new_size, new_size)) + + elif variation_level == "medium": + # 50% variation + for i, img in enumerate(images): + factor = 0.7 + (i % 4) * 0.2 # 0.7, 0.9, 1.1, 1.3 + new_size = int(base_size * factor) + sizes.append((new_size, new_size)) + + elif variation_level == "large": + # 100% variation + for i, img in enumerate(images): + factor = 0.5 + (i % 5) * 0.3 # 0.5, 0.8, 1.1, 1.4, 1.7 + new_size = int(base_size * factor) + sizes.append((new_size, new_size)) + + elif variation_level == "random": + # Random sizes + for _ in images: + factor = random.uniform(0.4, 1.8) + new_size = int(base_size * factor) + sizes.append((new_size, new_size)) + + return sizes + + def resize_image_proportional(self, img, target_size): + """Resize image maintaining aspect ratio""" + target_width, target_height = target_size + + # Calculate scale to fit within target size + scale_w = target_width / img.width + scale_h = target_height / img.height + scale = min(scale_w, scale_h) + + new_width = int(img.width * scale) + new_height = int(img.height * scale) + + return img.resize((new_width, new_height), Image.LANCZOS) + + def rotate_image(self, img, angle): + """Rotate image with transparent background""" + if angle == 0: + return img + + # Convert to RGBA for rotation + if img.mode != 'RGBA': + img = img.convert('RGBA') + + # Rotate with transparent background + rotated = img.rotate(angle, expand=True, fillcolor=(255, 255, 255, 0)) + return rotated + + def add_border(self, img, border_style, border_color=(0, 0, 0)): + """Add border to image""" + if border_style == "none": + return img + + if border_style == "thin": + border_width = 2 + elif border_style == "thick": + border_width = 5 + elif border_style == "shadow": + # Add drop shadow effect + shadow_offset = 3 + shadow_color = (128, 128, 128, 180) + + # Create shadow + shadow = Image.new('RGBA', + (img.width + shadow_offset, img.height + shadow_offset), + shadow_color) + + # Create result image + result = Image.new('RGBA', shadow.size, (255, 255, 255, 0)) + result.paste(shadow, (shadow_offset, shadow_offset)) + result.paste(img, (0, 0), img if img.mode == 'RGBA' else None) + + return result + else: + border_width = 2 + + # Add simple border + bordered = Image.new('RGB', + (img.width + 2 * border_width, img.height + 2 * border_width), + border_color) + bordered.paste(img, (border_width, border_width)) + + return bordered + + def check_overlap(self, pos1, size1, pos2, size2): + """Check if two rectangles overlap""" + x1, y1 = pos1 + w1, h1 = size1 + x2, y2 = pos2 + w2, h2 = size2 + + return not (x1 + w1 <= x2 or x2 + w2 <= x1 or y1 + h1 <= y2 or y2 + h2 <= y1) + + def find_non_overlapping_position(self, canvas_size, img_size, placed_items, max_attempts=100): + """Find a position that doesn't overlap with existing items""" + canvas_width, canvas_height = canvas_size + img_width, img_height = img_size + + for _ in range(max_attempts): + x = random.randint(0, max(0, canvas_width - img_width)) + y = random.randint(0, max(0, canvas_height - img_height)) + + # Check for overlaps + overlap_found = False + for placed_pos, placed_size in placed_items: + if self.check_overlap((x, y), img_size, placed_pos, placed_size): + overlap_found = True + break + + if not overlap_found: + return (x, y) + + # If no non-overlapping position found, return random position + x = random.randint(0, max(0, canvas_width - img_width)) + y = random.randint(0, max(0, canvas_height - img_height)) + return (x, y) + + def create_magazine_layout(self, images, sizes, canvas_size, spacing): + """Create magazine-style layout""" + positions = [] + canvas_width, canvas_height = canvas_size + + # Simple grid-based magazine layout + cols = int(math.sqrt(len(images))) + 1 + rows = math.ceil(len(images) / cols) + + cell_width = canvas_width // cols + cell_height = canvas_height // rows + + for i, (img, size) in enumerate(zip(images, sizes)): + row = i // cols + col = i % cols + + # Center in cell + cell_x = col * cell_width + cell_y = row * cell_height + + img_width, img_height = size + x = cell_x + (cell_width - img_width) // 2 + y = cell_y + (cell_height - img_height) // 2 + + # Ensure within bounds + x = max(0, min(x, canvas_width - img_width)) + y = max(0, min(y, canvas_height - img_height)) + + positions.append((x, y)) + + return positions + + def create_scattered_layout(self, images, sizes, canvas_size, overlap_allowed): + """Create scattered layout""" + positions = [] + placed_items = [] + + for img, size in zip(images, sizes): + if overlap_allowed: + # Random position + x = random.randint(0, max(0, canvas_size[0] - size[0])) + y = random.randint(0, max(0, canvas_size[1] - size[1])) + pos = (x, y) + else: + # Find non-overlapping position + pos = self.find_non_overlapping_position(canvas_size, size, placed_items) + + positions.append(pos) + placed_items.append((pos, size)) + + return positions + + def create_spiral_layout(self, images, sizes, canvas_size): + """Create spiral layout""" + positions = [] + center_x, center_y = canvas_size[0] // 2, canvas_size[1] // 2 + + angle = 0 + radius = 50 + + for i, (img, size) in enumerate(zip(images, sizes)): + # Calculate position on spiral + x = center_x + int(radius * math.cos(angle)) - size[0] // 2 + y = center_y + int(radius * math.sin(angle)) - size[1] // 2 + + # Ensure within bounds + x = max(0, min(x, canvas_size[0] - size[0])) + y = max(0, min(y, canvas_size[1] - size[1])) + + positions.append((x, y)) + + # Update for next position + angle += math.pi / 3 # 60 degrees + radius += 30 + + return positions + + def get_background_color(self, bg_type): + """Get background color""" + if bg_type == "white": + return (255, 255, 255) + elif bg_type == "black": + return (0, 0, 0) + elif bg_type == "random": + return (random.randint(200, 255), random.randint(200, 255), random.randint(200, 255)) + else: # transparent + return (255, 255, 255, 0) + + def create_collage(self, layout_style, canvas_width, canvas_height, size_variation, + rotation_enabled, max_rotation, overlap_allowed, background_color, + border_style, spacing, seed=42, **kwargs): + """Main function to create collage""" + + # Set random seed for reproducible results + random.seed(seed) + + # Get valid images + valid_images = self.get_valid_images(**kwargs) + + if not valid_images: + default_img = Image.new('RGB', (canvas_width, canvas_height), (128, 128, 128)) + return (self.pil_to_tensor(default_img),) + + canvas_size = (canvas_width, canvas_height) + + # Calculate size variations + target_sizes = self.calculate_size_variations(valid_images, canvas_size, size_variation) + + # Resize images + resized_images = [] + for img, target_size in zip(valid_images, target_sizes): + resized_img = self.resize_image_proportional(img, target_size) + + # Add rotation if enabled + if rotation_enabled: + angle = random.randint(-max_rotation, max_rotation) + resized_img = self.rotate_image(resized_img, angle) + + # Add border + resized_img = self.add_border(resized_img, border_style) + + resized_images.append(resized_img) + + # Get actual sizes after processing + actual_sizes = [(img.width, img.height) for img in resized_images] + + # Calculate positions based on layout style + if layout_style == "magazine": + positions = self.create_magazine_layout(resized_images, actual_sizes, canvas_size, spacing) + elif layout_style == "scattered": + positions = self.create_scattered_layout(resized_images, actual_sizes, canvas_size, overlap_allowed) + elif layout_style == "spiral": + positions = self.create_spiral_layout(resized_images, actual_sizes, canvas_size) + else: # photo_wall, mosaic, custom - use scattered for now + positions = self.create_scattered_layout(resized_images, actual_sizes, canvas_size, overlap_allowed) + + # Create canvas + bg_color = self.get_background_color(background_color) + if background_color == "transparent": + canvas = Image.new('RGBA', canvas_size, bg_color) + else: + canvas = Image.new('RGB', canvas_size, bg_color) + + # Place images on canvas + for img, pos in zip(resized_images, positions): + x, y = pos + + # Convert modes if necessary + if canvas.mode == 'RGBA' and img.mode != 'RGBA': + img = img.convert('RGBA') + elif canvas.mode == 'RGB' and img.mode == 'RGBA': + # Create white background for RGBA image + white_bg = Image.new('RGB', img.size, (255, 255, 255)) + white_bg.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = white_bg + + # Paste image + if img.mode == 'RGBA' and canvas.mode == 'RGBA': + canvas.paste(img, (x, y), img) + else: + canvas.paste(img, (x, y)) + + return (self.pil_to_tensor(canvas),) + +# Node registration +NODE_CLASS_MAPPINGS = { + "SmartCollageMaker": SmartCollageMaker +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SmartCollageMaker": "Smart Collage Maker" +} \ No newline at end of file diff --git a/smart_grid_stitch.py b/smart_grid_stitch.py new file mode 100644 index 0000000..b419792 --- /dev/null +++ b/smart_grid_stitch.py @@ -0,0 +1,313 @@ +import torch +import numpy as np +from PIL import Image +import math + +class SmartGridStitch: + """ + A ComfyUI node that arranges images in intelligent grid patterns. + Automatically handles missing slots and creates uniform grids. + """ + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "grid_mode": (["auto", "2x2", "3x3", "4x4", "2x3", "3x2", "1x6", "6x1"], {"default": "auto"}), + "fill_direction": (["row_first", "column_first"], {"default": "row_first"}), + "cell_sizing": (["largest", "smallest", "average", "custom"], {"default": "largest"}), + "aspect_ratio_handling": (["pad", "crop", "stretch"], {"default": "pad"}), + "resize_method": (["lanczos", "bilinear", "bicubic", "nearest"], {"default": "lanczos"}), + "gap_size": ("INT", {"default": 10, "min": 0, "max": 100, "step": 1}), + "background_color": (["transparent", "white", "black", "gray"], {"default": "white"}), + "empty_cell_style": (["skip", "placeholder", "repeat_last"], {"default": "skip"}), + }, + "optional": { + "custom_width": ("INT", {"default": 512, "min": 64, "max": 2048, "step": 8}), + "custom_height": ("INT", {"default": 512, "min": 64, "max": 2048, "step": 8}), + "image_1": ("IMAGE",), + "image_2": ("IMAGE",), + "image_3": ("IMAGE",), + "image_4": ("IMAGE",), + "image_5": ("IMAGE",), + "image_6": ("IMAGE",), + "image_7": ("IMAGE",), + "image_8": ("IMAGE",), + "image_9": ("IMAGE",), + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("grid_image",) + FUNCTION = "create_grid" + CATEGORY = "Niutonian/Image Processing" + + def tensor_to_pil(self, tensor): + """Convert tensor to PIL Image""" + if tensor is None: + return None + + if len(tensor.shape) == 4: + tensor = tensor[0] + + np_image = tensor.cpu().numpy() + + if np_image.dtype == np.float32 or np_image.dtype == np.float64: + np_image = (np_image * 255).astype(np.uint8) + + if len(np_image.shape) == 3: + return Image.fromarray(np_image, 'RGB') + else: + return Image.fromarray(np_image, 'L') + + def pil_to_tensor(self, pil_image): + """Convert PIL Image to tensor""" + if pil_image.mode != 'RGB': + pil_image = pil_image.convert('RGB') + + np_image = np.array(pil_image).astype(np.float32) / 255.0 + tensor = torch.from_numpy(np_image).unsqueeze(0) + return tensor + + def get_valid_images(self, **kwargs): + """Extract valid images from inputs""" + valid_images = [] + + for i in range(1, 10): # image_1 through image_9 + image_key = f"image_{i}" + if image_key in kwargs and kwargs[image_key] is not None: + pil_img = self.tensor_to_pil(kwargs[image_key]) + if pil_img is not None: + valid_images.append(pil_img) + + return valid_images + + def calculate_grid_dimensions(self, num_images, grid_mode): + """Calculate optimal grid dimensions""" + if grid_mode == "auto": + # Calculate best grid for number of images + if num_images <= 1: + return (1, 1) + elif num_images <= 4: + return (2, 2) + elif num_images <= 6: + return (2, 3) + elif num_images <= 9: + return (3, 3) + else: + # For more than 9, create rectangular grid + cols = math.ceil(math.sqrt(num_images)) + rows = math.ceil(num_images / cols) + return (rows, cols) + else: + # Parse explicit grid mode + parts = grid_mode.split('x') + return (int(parts[0]), int(parts[1])) + + def calculate_cell_size(self, images, cell_sizing, custom_width=None, custom_height=None): + """Calculate uniform cell size for all images""" + if not images: + return (512, 512) + + if cell_sizing == "custom" and custom_width and custom_height: + return (custom_width, custom_height) + + sizes = [(img.width, img.height) for img in images] + + if cell_sizing == "largest": + max_width = max(size[0] for size in sizes) + max_height = max(size[1] for size in sizes) + return (max_width, max_height) + elif cell_sizing == "smallest": + min_width = min(size[0] for size in sizes) + min_height = min(size[1] for size in sizes) + return (min_width, min_height) + elif cell_sizing == "average": + avg_width = sum(size[0] for size in sizes) // len(sizes) + avg_height = sum(size[1] for size in sizes) // len(sizes) + return (avg_width, avg_height) + + return (512, 512) + + def resize_image_to_cell(self, img, cell_size, aspect_handling, resize_method): + """Resize image to fit cell with aspect ratio handling""" + if img.size == cell_size: + return img + + methods = { + "lanczos": Image.LANCZOS, + "bilinear": Image.BILINEAR, + "bicubic": Image.BICUBIC, + "nearest": Image.NEAREST + } + pil_method = methods.get(resize_method, Image.LANCZOS) + + cell_width, cell_height = cell_size + + if aspect_handling == "stretch": + return img.resize(cell_size, pil_method) + + elif aspect_handling == "pad": + # Scale to fit and pad + scale_w = cell_width / img.width + scale_h = cell_height / img.height + scale = min(scale_w, scale_h) + + new_width = int(img.width * scale) + new_height = int(img.height * scale) + + resized_img = img.resize((new_width, new_height), pil_method) + + # Create cell canvas and center image + if img.mode == 'RGBA': + canvas = Image.new('RGBA', cell_size, (255, 255, 255, 0)) + else: + canvas = Image.new('RGB', cell_size, (255, 255, 255)) + + paste_x = (cell_width - new_width) // 2 + paste_y = (cell_height - new_height) // 2 + + if resized_img.mode == 'RGBA': + canvas.paste(resized_img, (paste_x, paste_y), resized_img) + else: + canvas.paste(resized_img, (paste_x, paste_y)) + + return canvas + + elif aspect_handling == "crop": + # Scale to fill and crop + scale_w = cell_width / img.width + scale_h = cell_height / img.height + scale = max(scale_w, scale_h) + + new_width = int(img.width * scale) + new_height = int(img.height * scale) + + resized_img = img.resize((new_width, new_height), pil_method) + + # Crop to cell size + crop_x = (new_width - cell_width) // 2 + crop_y = (new_height - cell_height) // 2 + + return resized_img.crop(( + crop_x, crop_y, + crop_x + cell_width, crop_y + cell_height + )) + + def create_placeholder_image(self, cell_size, background_color): + """Create placeholder for empty cells""" + if background_color == "transparent": + return Image.new('RGBA', cell_size, (255, 255, 255, 0)) + elif background_color == "white": + return Image.new('RGB', cell_size, (255, 255, 255)) + elif background_color == "black": + return Image.new('RGB', cell_size, (0, 0, 0)) + elif background_color == "gray": + return Image.new('RGB', cell_size, (128, 128, 128)) + else: + return Image.new('RGB', cell_size, (255, 255, 255)) + + def get_background_color_value(self, bg_type, mode='RGB'): + """Get background color value""" + if bg_type == "white": + return (255, 255, 255) if mode == 'RGB' else (255, 255, 255, 255) + elif bg_type == "black": + return (0, 0, 0) if mode == 'RGB' else (0, 0, 0, 255) + elif bg_type == "gray": + return (128, 128, 128) if mode == 'RGB' else (128, 128, 128, 255) + else: # transparent + return (255, 255, 255, 0) if mode == 'RGBA' else (255, 255, 255) + + def create_grid(self, grid_mode, fill_direction, cell_sizing, aspect_ratio_handling, + resize_method, gap_size, background_color, empty_cell_style, + custom_width=512, custom_height=512, **kwargs): + """Main function to create image grid""" + + # Get valid images + valid_images = self.get_valid_images(**kwargs) + + if not valid_images: + # Return default image if no valid images + default_img = Image.new('RGB', (512, 512), (128, 128, 128)) + return (self.pil_to_tensor(default_img),) + + # Calculate grid dimensions + rows, cols = self.calculate_grid_dimensions(len(valid_images), grid_mode) + total_cells = rows * cols + + # Calculate cell size + cell_size = self.calculate_cell_size(valid_images, cell_sizing, custom_width, custom_height) + cell_width, cell_height = cell_size + + # Resize all images to cell size + processed_images = [] + for img in valid_images: + processed_img = self.resize_image_to_cell(img, cell_size, aspect_ratio_handling, resize_method) + processed_images.append(processed_img) + + # Handle empty cells + while len(processed_images) < total_cells: + if empty_cell_style == "skip": + break + elif empty_cell_style == "placeholder": + placeholder = self.create_placeholder_image(cell_size, background_color) + processed_images.append(placeholder) + elif empty_cell_style == "repeat_last" and processed_images: + processed_images.append(processed_images[-1].copy()) + else: + break + + # Calculate canvas size + canvas_width = cols * cell_width + (cols - 1) * gap_size + canvas_height = rows * cell_height + (rows - 1) * gap_size + + # Create canvas + if background_color == "transparent": + canvas = Image.new('RGBA', (canvas_width, canvas_height), + self.get_background_color_value(background_color, 'RGBA')) + else: + canvas = Image.new('RGB', (canvas_width, canvas_height), + self.get_background_color_value(background_color, 'RGB')) + + # Place images in grid + img_index = 0 + for row in range(rows): + for col in range(cols): + if img_index >= len(processed_images): + break + + # Calculate position + x = col * (cell_width + gap_size) + y = row * (cell_height + gap_size) + + img = processed_images[img_index] + + # Convert modes if necessary + if canvas.mode == 'RGBA' and img.mode != 'RGBA': + img = img.convert('RGBA') + elif canvas.mode == 'RGB' and img.mode == 'RGBA': + white_bg = Image.new('RGB', img.size, (255, 255, 255)) + white_bg.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = white_bg + + # Paste image + if img.mode == 'RGBA': + canvas.paste(img, (x, y), img) + else: + canvas.paste(img, (x, y)) + + img_index += 1 + + return (self.pil_to_tensor(canvas),) + +# Node registration +NODE_CLASS_MAPPINGS = { + "SmartGridStitch": SmartGridStitch +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SmartGridStitch": "Smart Grid Stitch" +} \ No newline at end of file diff --git a/smart_image_splitter.py b/smart_image_splitter.py new file mode 100644 index 0000000..cb8c7e4 --- /dev/null +++ b/smart_image_splitter.py @@ -0,0 +1,178 @@ +import torch +import numpy as np +from PIL import Image +import math + +class SmartImageSplitter: + """ + A ComfyUI node for intelligently splitting images into multiple parts. + """ + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "input_image": ("IMAGE",), + "split_mode": (["grid", "horizontal", "vertical", "custom"], {"default": "grid"}), + "grid_size": (["2x2", "3x3", "4x4", "2x3", "3x2", "1x4", "4x1"], {"default": "2x2"}), + "overlap_pixels": ("INT", {"default": 0, "min": 0, "max": 100, "step": 1}), + "output_format": (["individual", "batch"], {"default": "individual"}), + }, + "optional": { + "custom_rows": ("INT", {"default": 2, "min": 1, "max": 10}), + "custom_cols": ("INT", {"default": 2, "min": 1, "max": 10}), + "horizontal_splits": ("INT", {"default": 2, "min": 2, "max": 10}), + "vertical_splits": ("INT", {"default": 2, "min": 2, "max": 10}), + } + } + + RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE", "IMAGE", "IMAGE", "IMAGE", "IMAGE", "IMAGE", "IMAGE") + RETURN_NAMES = ("split_1", "split_2", "split_3", "split_4", "split_5", "split_6", "split_7", "split_8", "split_9") + FUNCTION = "split_image" + CATEGORY = "Niutonian/Image Processing" + + def tensor_to_pil(self, tensor): + """Convert tensor to PIL Image""" + if tensor is None: + return None + + if len(tensor.shape) == 4: + tensor = tensor[0] + + np_image = tensor.cpu().numpy() + + if np_image.dtype == np.float32 or np_image.dtype == np.float64: + np_image = (np_image * 255).astype(np.uint8) + + if len(np_image.shape) == 3: + return Image.fromarray(np_image, 'RGB') + else: + return Image.fromarray(np_image, 'L') + + def pil_to_tensor(self, pil_image): + """Convert PIL Image to tensor""" + if pil_image.mode != 'RGB': + pil_image = pil_image.convert('RGB') + + np_image = np.array(pil_image).astype(np.float32) / 255.0 + tensor = torch.from_numpy(np_image).unsqueeze(0) + return tensor + + def parse_grid_size(self, grid_size): + """Parse grid size string to rows and columns""" + parts = grid_size.split('x') + return int(parts[0]), int(parts[1]) + + def split_into_grid(self, image, rows, cols, overlap): + """Split image into grid with optional overlap""" + img_width, img_height = image.size + + # Calculate cell dimensions + cell_width = img_width // cols + cell_height = img_height // rows + + splits = [] + + for row in range(rows): + for col in range(cols): + # Calculate base position + x = col * cell_width + y = row * cell_height + + # Add overlap + x_start = max(0, x - overlap) + y_start = max(0, y - overlap) + x_end = min(img_width, x + cell_width + overlap) + y_end = min(img_height, y + cell_height + overlap) + + # Crop the section + split_img = image.crop((x_start, y_start, x_end, y_end)) + splits.append(split_img) + + return splits + + def split_horizontal(self, image, num_splits, overlap): + """Split image horizontally""" + img_width, img_height = image.size + split_width = img_width // num_splits + + splits = [] + + for i in range(num_splits): + x_start = max(0, i * split_width - overlap) + x_end = min(img_width, (i + 1) * split_width + overlap) + + split_img = image.crop((x_start, 0, x_end, img_height)) + splits.append(split_img) + + return splits + + def split_vertical(self, image, num_splits, overlap): + """Split image vertically""" + img_width, img_height = image.size + split_height = img_height // num_splits + + splits = [] + + for i in range(num_splits): + y_start = max(0, i * split_height - overlap) + y_end = min(img_height, (i + 1) * split_height + overlap) + + split_img = image.crop((0, y_start, img_width, y_end)) + splits.append(split_img) + + return splits + + def create_empty_tensor(self): + """Create empty tensor for unused outputs""" + empty_img = Image.new('RGB', (64, 64), (0, 0, 0)) + return self.pil_to_tensor(empty_img) + + def split_image(self, input_image, split_mode, grid_size, overlap_pixels, output_format, + custom_rows=2, custom_cols=2, horizontal_splits=2, vertical_splits=2): + """Main function to split image""" + + # Convert input to PIL + pil_image = self.tensor_to_pil(input_image) + + if pil_image is None: + # Return empty tensors for all outputs + empty = self.create_empty_tensor() + return tuple([empty] * 9) + + splits = [] + + if split_mode == "grid": + rows, cols = self.parse_grid_size(grid_size) + splits = self.split_into_grid(pil_image, rows, cols, overlap_pixels) + + elif split_mode == "custom": + splits = self.split_into_grid(pil_image, custom_rows, custom_cols, overlap_pixels) + + elif split_mode == "horizontal": + splits = self.split_horizontal(pil_image, horizontal_splits, overlap_pixels) + + elif split_mode == "vertical": + splits = self.split_vertical(pil_image, vertical_splits, overlap_pixels) + + # Convert splits to tensors + result_tensors = [] + for i in range(9): # Maximum 9 outputs + if i < len(splits): + result_tensors.append(self.pil_to_tensor(splits[i])) + else: + result_tensors.append(self.create_empty_tensor()) + + return tuple(result_tensors) + +# Node registration +NODE_CLASS_MAPPINGS = { + "SmartImageSplitter": SmartImageSplitter +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SmartImageSplitter": "Smart Image Splitter" +} \ No newline at end of file diff --git a/smart_image_stitch.py b/smart_image_stitch.py new file mode 100644 index 0000000..7a9fdec --- /dev/null +++ b/smart_image_stitch.py @@ -0,0 +1,338 @@ +import torch +import numpy as np +from PIL import Image, ImageOps +import comfy.utils + +class SmartImageStitch: + """ + A ComfyUI node that intelligently stitches multiple images together. + Automatically ignores empty/bypassed inputs and aligns remaining images + in the specified direction. + """ + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "direction": (["left", "right", "top", "bottom"], {"default": "right"}), + "alignment": (["start", "center", "end"], {"default": "center"}), + "resize_mode": (["none", "largest", "smallest", "longest_side", "shortest_side"], {"default": "none"}), + "resize_method": (["lanczos", "bilinear", "bicubic", "nearest"], {"default": "lanczos"}), + "aspect_ratio_handling": (["pad", "crop", "stretch"], {"default": "pad"}), + "spacing": ("INT", {"default": 0, "min": 0, "max": 100, "step": 1}), + "background_color": (["transparent", "white", "black"], {"default": "transparent"}), + }, + "optional": { + "image_1": ("IMAGE",), + "image_2": ("IMAGE",), + "image_3": ("IMAGE",), + "image_4": ("IMAGE",), + "image_5": ("IMAGE",), + "image_6": ("IMAGE",), + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("stitched_image",) + FUNCTION = "stitch_images" + CATEGORY = "Niutonian/Image Processing" + + def tensor_to_pil(self, tensor): + """Convert tensor to PIL Image""" + if tensor is None: + return None + + # Handle batch dimension + if len(tensor.shape) == 4: + tensor = tensor[0] + + # Convert from torch tensor to numpy + np_image = tensor.cpu().numpy() + + # Convert from float [0,1] to uint8 [0,255] + if np_image.dtype == np.float32 or np_image.dtype == np.float64: + np_image = (np_image * 255).astype(np.uint8) + + # Convert to PIL Image + if len(np_image.shape) == 3: + return Image.fromarray(np_image, 'RGB') + else: + return Image.fromarray(np_image, 'L') + + def pil_to_tensor(self, pil_image): + """Convert PIL Image to tensor""" + if pil_image.mode != 'RGB': + pil_image = pil_image.convert('RGB') + + np_image = np.array(pil_image).astype(np.float32) / 255.0 + tensor = torch.from_numpy(np_image).unsqueeze(0) # Add batch dimension + return tensor + + def get_resize_method(self, method_name): + """Get PIL resize method from string""" + methods = { + "lanczos": Image.LANCZOS, + "bilinear": Image.BILINEAR, + "bicubic": Image.BICUBIC, + "nearest": Image.NEAREST + } + return methods.get(method_name, Image.LANCZOS) + + def calculate_target_size(self, images, resize_mode, direction): + """Calculate target size for all images based on resize mode""" + if not images or resize_mode == "none": + return None + + sizes = [(img.width, img.height) for img in images] + + if resize_mode == "largest": + # Resize all to match the largest image dimensions + max_width = max(size[0] for size in sizes) + max_height = max(size[1] for size in sizes) + return (max_width, max_height) + + elif resize_mode == "smallest": + # Resize all to match the smallest image dimensions + min_width = min(size[0] for size in sizes) + min_height = min(size[1] for size in sizes) + return (min_width, min_height) + + elif resize_mode == "longest_side": + # Find the longest side across all images and make all images square to that size + max_dimension = max(max(size) for size in sizes) + return (max_dimension, max_dimension) + + elif resize_mode == "shortest_side": + # Find the shortest side across all images and make all images square to that size + min_dimension = min(min(size) for size in sizes) + return (min_dimension, min_dimension) + + return None + + def resize_images_to_target(self, images, target_size, resize_method, aspect_handling): + """Resize all images to target size while handling aspect ratios""" + if target_size is None: + return images + + pil_method = self.get_resize_method(resize_method) + resized_images = [] + target_width, target_height = target_size + + for img in images: + if img.size == target_size: + resized_images.append(img) + continue + + original_width, original_height = img.size + + if aspect_handling == "stretch": + # Simple stretch (original behavior) + resized_img = img.resize(target_size, pil_method) + resized_images.append(resized_img) + + elif aspect_handling == "pad": + # Maintain aspect ratio and pad with background + # Calculate scale to fit within target size + scale_w = target_width / original_width + scale_h = target_height / original_height + scale = min(scale_w, scale_h) # Use smaller scale to fit within bounds + + # Calculate new size maintaining aspect ratio + new_width = int(original_width * scale) + new_height = int(original_height * scale) + + # Resize image maintaining aspect ratio + resized_img = img.resize((new_width, new_height), pil_method) + + # Create canvas with target size and paste resized image in center + if img.mode == 'RGBA': + canvas = Image.new('RGBA', target_size, (255, 255, 255, 0)) + else: + canvas = Image.new('RGB', target_size, (255, 255, 255)) + + # Calculate position to center the image + paste_x = (target_width - new_width) // 2 + paste_y = (target_height - new_height) // 2 + + if resized_img.mode == 'RGBA': + canvas.paste(resized_img, (paste_x, paste_y), resized_img) + else: + canvas.paste(resized_img, (paste_x, paste_y)) + + resized_images.append(canvas) + + elif aspect_handling == "crop": + # Maintain aspect ratio and crop to fill target size + # Calculate scale to fill target size + scale_w = target_width / original_width + scale_h = target_height / original_height + scale = max(scale_w, scale_h) # Use larger scale to fill bounds + + # Calculate new size maintaining aspect ratio + new_width = int(original_width * scale) + new_height = int(original_height * scale) + + # Resize image maintaining aspect ratio + resized_img = img.resize((new_width, new_height), pil_method) + + # Calculate crop box to center the crop + crop_x = (new_width - target_width) // 2 + crop_y = (new_height - target_height) // 2 + + # Crop to target size + cropped_img = resized_img.crop(( + crop_x, + crop_y, + crop_x + target_width, + crop_y + target_height + )) + + resized_images.append(cropped_img) + + return resized_images + + def get_valid_images(self, **kwargs): + """Extract valid (non-None) images from inputs""" + valid_images = [] + + # Check each possible image input + for i in range(1, 7): # image_1 through image_6 + image_key = f"image_{i}" + if image_key in kwargs and kwargs[image_key] is not None: + pil_img = self.tensor_to_pil(kwargs[image_key]) + if pil_img is not None: + valid_images.append(pil_img) + + return valid_images + + def calculate_canvas_size(self, images, direction, spacing): + """Calculate the size needed for the final canvas""" + if not images: + return (100, 100) # Default size if no images + + if direction in ["left", "right"]: + # Horizontal stitching + total_width = sum(img.width for img in images) + spacing * (len(images) - 1) + max_height = max(img.height for img in images) + return (total_width, max_height) + else: + # Vertical stitching + max_width = max(img.width for img in images) + total_height = sum(img.height for img in images) + spacing * (len(images) - 1) + return (max_width, total_height) + + def get_background_color(self, bg_type, mode='RGB'): + """Get background color based on type""" + if bg_type == "white": + return (255, 255, 255) if mode == 'RGB' else 255 + elif bg_type == "black": + return (0, 0, 0) if mode == 'RGB' else 0 + else: # transparent + return (255, 255, 255, 0) if mode == 'RGBA' else (255, 255, 255) + + def calculate_position(self, img_size, canvas_size, alignment, direction, current_offset): + """Calculate position for placing an image on the canvas""" + img_width, img_height = img_size + canvas_width, canvas_height = canvas_size + + if direction in ["left", "right"]: + # Horizontal stitching + x = current_offset + if alignment == "start": + y = 0 + elif alignment == "center": + y = (canvas_height - img_height) // 2 + else: # end + y = canvas_height - img_height + else: + # Vertical stitching + y = current_offset + if alignment == "start": + x = 0 + elif alignment == "center": + x = (canvas_width - img_width) // 2 + else: # end + x = canvas_width - img_width + + return (x, y) + + def stitch_images(self, direction, alignment, resize_mode, resize_method, aspect_ratio_handling, spacing, background_color, **kwargs): + """Main function to stitch images together""" + + # Get all valid images + valid_images = self.get_valid_images(**kwargs) + + if not valid_images: + # Return a small default image if no valid images + default_img = Image.new('RGB', (100, 100), (128, 128, 128)) + return (self.pil_to_tensor(default_img),) + + if len(valid_images) == 1: + # Return single image if only one valid image + return (self.pil_to_tensor(valid_images[0]),) + + # Apply resizing if specified + target_size = self.calculate_target_size(valid_images, resize_mode, direction) + if target_size: + valid_images = self.resize_images_to_target(valid_images, target_size, resize_method, aspect_ratio_handling) + + # Reverse order for left and top directions to maintain logical flow + if direction in ["left", "top"]: + valid_images = valid_images[::-1] + + # Calculate canvas size + canvas_size = self.calculate_canvas_size(valid_images, direction, spacing) + + # Create canvas + if background_color == "transparent": + canvas = Image.new('RGBA', canvas_size, self.get_background_color(background_color, 'RGBA')) + else: + canvas = Image.new('RGB', canvas_size, self.get_background_color(background_color, 'RGB')) + + # Place images on canvas + current_offset = 0 + + for i, img in enumerate(valid_images): + # Convert to same mode as canvas if needed + if canvas.mode == 'RGBA' and img.mode != 'RGBA': + img = img.convert('RGBA') + elif canvas.mode == 'RGB' and img.mode == 'RGBA': + # Create a white background and paste the RGBA image onto it + white_bg = Image.new('RGB', img.size, (255, 255, 255)) + white_bg.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = white_bg + + # Calculate position + pos = self.calculate_position(img.size, canvas_size, alignment, direction, current_offset) + + # Paste image + if img.mode == 'RGBA': + canvas.paste(img, pos, img) + else: + canvas.paste(img, pos) + + # Update offset for next image + if direction in ["left", "right"]: + current_offset += img.width + spacing + else: + current_offset += img.height + spacing + + # Convert back to RGB if it was RGBA but background wasn't transparent + if canvas.mode == 'RGBA' and background_color != "transparent": + rgb_canvas = Image.new('RGB', canvas.size, self.get_background_color(background_color, 'RGB')) + rgb_canvas.paste(canvas, mask=canvas.split()[-1]) + canvas = rgb_canvas + + return (self.pil_to_tensor(canvas),) + +# Node registration +NODE_CLASS_MAPPINGS = { + "SmartImageStitch": SmartImageStitch +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SmartImageStitch": "Smart Image Stitch" +} \ No newline at end of file diff --git a/smart_panorama_stitch.py b/smart_panorama_stitch.py new file mode 100644 index 0000000..5b9da5e --- /dev/null +++ b/smart_panorama_stitch.py @@ -0,0 +1,345 @@ +import torch +import numpy as np +from PIL import Image +import cv2 + +class SmartPanoramaStitch: + """ + A ComfyUI node for intelligent panoramic image stitching. + Automatically detects overlaps and blends images seamlessly. + """ + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "stitch_direction": (["horizontal", "vertical"], {"default": "horizontal"}), + "overlap_detection": (["auto", "manual"], {"default": "auto"}), + "blend_mode": (["linear", "multiband", "feather"], {"default": "linear"}), + "feature_detector": (["sift", "orb", "akaze"], {"default": "sift"}), + "match_confidence": ("FLOAT", {"default": 0.3, "min": 0.1, "max": 1.0, "step": 0.1}), + "blend_strength": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 1.0, "step": 0.1}), + "crop_result": ("BOOLEAN", {"default": True}), + }, + "optional": { + "manual_overlap": ("INT", {"default": 100, "min": 10, "max": 500, "step": 10}), + "image_1": ("IMAGE",), + "image_2": ("IMAGE",), + "image_3": ("IMAGE",), + "image_4": ("IMAGE",), + "image_5": ("IMAGE",), + "image_6": ("IMAGE",), + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("panorama_image",) + FUNCTION = "stitch_panorama" + CATEGORY = "Niutonian/Image Processing" + + def tensor_to_pil(self, tensor): + """Convert tensor to PIL Image""" + if tensor is None: + return None + + if len(tensor.shape) == 4: + tensor = tensor[0] + + np_image = tensor.cpu().numpy() + + if np_image.dtype == np.float32 or np_image.dtype == np.float64: + np_image = (np_image * 255).astype(np.uint8) + + if len(np_image.shape) == 3: + return Image.fromarray(np_image, 'RGB') + else: + return Image.fromarray(np_image, 'L') + + def pil_to_tensor(self, pil_image): + """Convert PIL Image to tensor""" + if pil_image.mode != 'RGB': + pil_image = pil_image.convert('RGB') + + np_image = np.array(pil_image).astype(np.float32) / 255.0 + tensor = torch.from_numpy(np_image).unsqueeze(0) + return tensor + + def pil_to_cv2(self, pil_image): + """Convert PIL to OpenCV format""" + return cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR) + + def cv2_to_pil(self, cv2_image): + """Convert OpenCV to PIL format""" + return Image.fromarray(cv2.cvtColor(cv2_image, cv2.COLOR_BGR2RGB)) + + def get_valid_images(self, **kwargs): + """Extract valid images from inputs""" + valid_images = [] + + for i in range(1, 7): # image_1 through image_6 + image_key = f"image_{i}" + if image_key in kwargs and kwargs[image_key] is not None: + pil_img = self.tensor_to_pil(kwargs[image_key]) + if pil_img is not None: + valid_images.append(pil_img) + + return valid_images + + def detect_and_match_features(self, img1, img2, detector_type, confidence): + """Detect and match features between two images""" + # Convert to grayscale + gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY) + gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY) + + # Initialize detector + if detector_type == "sift": + detector = cv2.SIFT_create() + elif detector_type == "orb": + detector = cv2.ORB_create() + elif detector_type == "akaze": + detector = cv2.AKAZE_create() + else: + detector = cv2.SIFT_create() + + # Detect keypoints and descriptors + kp1, desc1 = detector.detectAndCompute(gray1, None) + kp2, desc2 = detector.detectAndCompute(gray2, None) + + if desc1 is None or desc2 is None: + return None, None + + # Match features + if detector_type == "orb": + matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True) + matches = matcher.match(desc1, desc2) + matches = sorted(matches, key=lambda x: x.distance) + else: + matcher = cv2.BFMatcher() + matches = matcher.knnMatch(desc1, desc2, k=2) + + # Apply ratio test + good_matches = [] + for match_pair in matches: + if len(match_pair) == 2: + m, n = match_pair + if m.distance < confidence * n.distance: + good_matches.append(m) + matches = good_matches + + if len(matches) < 4: + return None, None + + # Extract matched points + src_pts = np.float32([kp1[m.queryIdx].pt for m in matches]).reshape(-1, 1, 2) + dst_pts = np.float32([kp2[m.trainIdx].pt for m in matches]).reshape(-1, 1, 2) + + return src_pts, dst_pts + + def find_homography_and_stitch(self, img1, img2, src_pts, dst_pts): + """Find homography and stitch images""" + # Find homography + homography, mask = cv2.findHomography(src_pts, dst_pts, + cv2.RANSAC, 5.0) + + if homography is None: + return None + + # Get image dimensions + h1, w1 = img1.shape[:2] + h2, w2 = img2.shape[:2] + + # Get corners of first image + corners1 = np.float32([[0, 0], [w1, 0], [w1, h1], [0, h1]]).reshape(-1, 1, 2) + corners2 = np.float32([[0, 0], [w2, 0], [w2, h2], [0, h2]]).reshape(-1, 1, 2) + + # Transform corners of first image + corners1_transformed = cv2.perspectiveTransform(corners1, homography) + + # Combine all corners + all_corners = np.concatenate((corners2, corners1_transformed), axis=0) + + # Find bounding rectangle + [x_min, y_min] = np.int32(all_corners.min(axis=0).ravel()) + [x_max, y_max] = np.int32(all_corners.max(axis=0).ravel()) + + # Translation matrix + translation = np.array([[1, 0, -x_min], [0, 1, -y_min], [0, 0, 1]]) + + # Apply translation to homography + homography_translated = translation.dot(homography) + + # Warp first image + result_width = x_max - x_min + result_height = y_max - y_min + + warped_img1 = cv2.warpPerspective(img1, homography_translated, + (result_width, result_height)) + + # Create result image + result = np.zeros((result_height, result_width, 3), dtype=np.uint8) + + # Place second image + result[-y_min:-y_min + h2, -x_min:-x_min + w2] = img2 + + # Blend with warped first image + mask = (warped_img1 > 0).astype(np.uint8) + result = cv2.bitwise_and(result, cv2.bitwise_not(mask * 255)) + warped_img1 + + return result + + def simple_blend_stitch(self, images, direction, manual_overlap): + """Simple overlap-based stitching for manual mode""" + if len(images) < 2: + return images[0] if images else None + + result = self.pil_to_cv2(images[0]) + + for i in range(1, len(images)): + img2_cv = self.pil_to_cv2(images[i]) + + if direction == "horizontal": + # Horizontal stitching with overlap + h1, w1 = result.shape[:2] + h2, w2 = img2_cv.shape[:2] + + # Resize to same height if needed + if h1 != h2: + target_height = min(h1, h2) + result = cv2.resize(result, (int(w1 * target_height / h1), target_height)) + img2_cv = cv2.resize(img2_cv, (int(w2 * target_height / h2), target_height)) + h1, w1 = result.shape[:2] + h2, w2 = img2_cv.shape[:2] + + # Create blended result + overlap = min(manual_overlap, w1, w2) + new_width = w1 + w2 - overlap + blended = np.zeros((h1, new_width, 3), dtype=np.uint8) + + # Place first image + blended[:, :w1] = result + + # Blend overlap region + for x in range(overlap): + alpha = x / overlap + blend_x = w1 - overlap + x + if blend_x < new_width and x < w2: + blended[:, blend_x] = (1 - alpha) * result[:, blend_x] + alpha * img2_cv[:, x] + + # Place remaining part of second image + remaining_start = w1 + remaining_img_start = overlap + remaining_width = w2 - overlap + if remaining_width > 0: + blended[:, remaining_start:remaining_start + remaining_width] = img2_cv[:, remaining_img_start:] + + result = blended + + else: # vertical + # Vertical stitching with overlap + h1, w1 = result.shape[:2] + h2, w2 = img2_cv.shape[:2] + + # Resize to same width if needed + if w1 != w2: + target_width = min(w1, w2) + result = cv2.resize(result, (target_width, int(h1 * target_width / w1))) + img2_cv = cv2.resize(img2_cv, (target_width, int(h2 * target_width / w2))) + h1, w1 = result.shape[:2] + h2, w2 = img2_cv.shape[:2] + + # Create blended result + overlap = min(manual_overlap, h1, h2) + new_height = h1 + h2 - overlap + blended = np.zeros((new_height, w1, 3), dtype=np.uint8) + + # Place first image + blended[:h1, :] = result + + # Blend overlap region + for y in range(overlap): + alpha = y / overlap + blend_y = h1 - overlap + y + if blend_y < new_height and y < h2: + blended[blend_y, :] = (1 - alpha) * result[blend_y, :] + alpha * img2_cv[y, :] + + # Place remaining part of second image + remaining_start = h1 + remaining_img_start = overlap + remaining_height = h2 - overlap + if remaining_height > 0: + blended[remaining_start:remaining_start + remaining_height, :] = img2_cv[remaining_img_start:, :] + + result = blended + + return self.cv2_to_pil(result) + + def stitch_panorama(self, stitch_direction, overlap_detection, blend_mode, + feature_detector, match_confidence, blend_strength, crop_result, + manual_overlap=100, **kwargs): + """Main function to create panorama""" + + # Get valid images + valid_images = self.get_valid_images(**kwargs) + + if not valid_images: + default_img = Image.new('RGB', (512, 512), (128, 128, 128)) + return (self.pil_to_tensor(default_img),) + + if len(valid_images) == 1: + return (self.pil_to_tensor(valid_images[0]),) + + try: + if overlap_detection == "manual": + # Use simple overlap-based stitching + result = self.simple_blend_stitch(valid_images, stitch_direction, manual_overlap) + else: + # Use feature-based stitching + result_cv = self.pil_to_cv2(valid_images[0]) + + for i in range(1, len(valid_images)): + img2_cv = self.pil_to_cv2(valid_images[i]) + + # Detect and match features + src_pts, dst_pts = self.detect_and_match_features( + result_cv, img2_cv, feature_detector, match_confidence) + + if src_pts is not None and dst_pts is not None: + # Stitch using homography + stitched = self.find_homography_and_stitch(result_cv, img2_cv, src_pts, dst_pts) + if stitched is not None: + result_cv = stitched + else: + # Fallback to simple stitching + remaining_images = [self.cv2_to_pil(result_cv)] + valid_images[i:] + result = self.simple_blend_stitch(remaining_images, stitch_direction, manual_overlap) + break + else: + # Fallback to simple stitching + remaining_images = [self.cv2_to_pil(result_cv)] + valid_images[i:] + result = self.simple_blend_stitch(remaining_images, stitch_direction, manual_overlap) + break + else: + result = self.cv2_to_pil(result_cv) + + if result is None: + result = valid_images[0] + + return (self.pil_to_tensor(result),) + + except Exception as e: + # Fallback to simple concatenation + print(f"Panorama stitching failed: {e}, falling back to simple concatenation") + result = self.simple_blend_stitch(valid_images, stitch_direction, manual_overlap) + return (self.pil_to_tensor(result),) + +# Node registration +NODE_CLASS_MAPPINGS = { + "SmartPanoramaStitch": SmartPanoramaStitch +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SmartPanoramaStitch": "Smart Panorama Stitch" +} \ No newline at end of file