Initial commit: Complete Niutonian Smart Image Suite with 7 nodes
This commit is contained in:
+48
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
+29
@@ -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']
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user