Initial commit: Complete Niutonian Smart Image Suite with 7 nodes

This commit is contained in:
Niutonian
2026-01-27 13:39:37 +01:00
commit 80711e1f99
12 changed files with 2523 additions and 0 deletions
+48
View File
@@ -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/
+21
View File
@@ -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.
+112
View File
@@ -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
View File
@@ -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']
+8
View File
@@ -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
+381
View File
@@ -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"
}
+351
View File
@@ -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"
}
+399
View File
@@ -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"
}
+313
View File
@@ -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"
}
+178
View File
@@ -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"
}
+338
View File
@@ -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"
}
+345
View File
@@ -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"
}