Files
Limbicnation-ComfyUI-Transp…/nodes.py
T
limbicnation 3d51cabb92 fix(edge-detection): resolve critical runtime errors and improve stability
Fixed IndexError in combine_edge_maps when input is empty
Prevented ZeroDivisionError in enhanced_sobel_edge_detection for solid colors
Fixed NaN output in detect_pixel_art_characteristics when no edges found
Implemented true automatic mode by integrating content detection logic
2025-08-19 01:30:07 +02:00

612 lines
25 KiB
Python

import os
import numpy as np
import torch
from PIL import Image
import cv2
from sklearn.cluster import KMeans
import colorsys
# Handle ComfyUI imports gracefully for testing
try:
import folder_paths
import comfy.utils
COMFYUI_AVAILABLE = True
except ImportError:
# Mock modules for testing/CI
import sys
from types import ModuleType
# Create mock folder_paths
folder_paths = ModuleType('folder_paths')
folder_paths.get_input_directory = lambda: '/tmp'
sys.modules['folder_paths'] = folder_paths
# Create mock comfy.utils
comfy = ModuleType('comfy')
comfy.utils = ModuleType('comfy.utils')
comfy.utils.common_upscale = lambda image, width, height, upscale_method, crop: image
sys.modules['comfy'] = comfy
sys.modules['comfy.utils'] = comfy.utils
COMFYUI_AVAILABLE = False
class TransparencyBackgroundRemover:
"""
ComfyUI node for automatic background removal with transparency generation.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"tolerance": ("INT", {
"default": 30,
"min": 0,
"max": 255,
"step": 1,
"display": "number",
"tooltip": "Color similarity threshold for background detection (0-255)"
}),
"edge_sensitivity": ("FLOAT", {
"default": 0.8,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"display": "slider",
"tooltip": "Edge detection sensitivity (0-1)"
}),
"foreground_bias": ("FLOAT", {
"default": 0.7,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"display": "slider",
"tooltip": "Bias towards foreground preservation (0-1)"
}),
"color_clusters": ("INT", {
"default": 8,
"min": 2,
"max": 20,
"step": 1,
"display": "number",
"tooltip": "Number of color clusters for background detection"
}),
"binary_threshold": ("INT", {
"default": 128,
"min": 0,
"max": 255,
"step": 1,
"display": "number",
"tooltip": "Threshold for binary alpha mask (0-255)"
}),
"output_size": (["ORIGINAL", "64x64", "96x96", "128x128", "256x256", "512x512", "768x768", "1024x1024", "1280x1280", "1536x1536", "1792x1792", "2048x2048"], {
"default": "ORIGINAL",
"tooltip": "Target output size (power-of-8 dimensions for optimal scaling)"
}),
"scaling_method": (["NEAREST", "BILINEAR", "BICUBIC", "LANCZOS"], {
"default": "NEAREST",
"tooltip": "Interpolation method: NEAREST (pixel-perfect), BILINEAR (smooth), BICUBIC (high-quality), LANCZOS (best quality)"
}),
},
"optional": {
"edge_refinement": ("BOOLEAN", {
"default": True,
"tooltip": "Apply edge refinement post-processing"
}),
"dither_handling": ("BOOLEAN", {
"default": True,
"tooltip": "Enable dithered pattern detection and handling"
}),
"output_format": (["RGBA", "RGB_WITH_MASK"], {
"default": "RGBA",
"tooltip": "Output format: RGBA with alpha channel or RGB with separate mask"
}),
"auto_adjust": ("BOOLEAN", {
"default": False,
"tooltip": "Automatically adjust parameters based on image content analysis"
}),
"edge_detection_mode": (["AUTO", "PIXEL_ART", "PHOTOGRAPHIC"], {
"default": "AUTO",
"tooltip": "Edge detection optimization: AUTO (detect content type), PIXEL_ART (sharp edges), PHOTOGRAPHIC (smooth edges)"
}),
}
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "mask")
FUNCTION = "remove_background"
CATEGORY = "image/processing"
def __init__(self):
self.processor = None
def parse_output_size(self, size_string):
"""
Parse output size string to width, height tuple.
Args:
size_string: String like "512x512" or "ORIGINAL"
Returns:
Tuple (width, height) or None for ORIGINAL
"""
if size_string == "ORIGINAL":
return None
try:
width, height = size_string.split('x')
return (int(width), int(height))
except ValueError:
raise ValueError(f"Invalid output size format: {size_string}")
def calculate_scaling_factor(self, current_size, target_size):
"""
Calculate optimal integer scaling factor for NEAREST interpolation.
Args:
current_size: Tuple (width, height) of current image
target_size: Tuple (width, height) of target size
Returns:
Scaling factor that achieves target size
"""
current_w, current_h = current_size
target_w, target_h = target_size
# Calculate scale factors for width and height
scale_w = target_w / current_w
scale_h = target_h / current_h
# Use the same scale for both dimensions to maintain aspect ratio
# Choose the smaller scale to ensure we don't exceed target dimensions
scale_factor = min(scale_w, scale_h)
return scale_factor
def intelligent_scale(self, image_pil, target_size, scaling_method="NEAREST"):
"""
Scale image to target dimensions using specified interpolation method.
Args:
image_pil: PIL Image object
target_size: Tuple (width, height) for target dimensions
scaling_method: Interpolation method ("NEAREST", "BILINEAR", "BICUBIC", "LANCZOS")
Returns:
Scaled PIL Image
"""
if target_size is None:
return image_pil
current_size = (image_pil.width, image_pil.height)
target_w, target_h = target_size
# If already at target size, return as-is
if current_size == target_size:
return image_pil
# Calculate scaling factor
scale_factor = self.calculate_scaling_factor(current_size, target_size)
# Apply scaling
new_width = int(image_pil.width * scale_factor)
new_height = int(image_pil.height * scale_factor)
# If calculated size matches target exactly, use target dimensions
if abs(new_width - target_w) <= 1 and abs(new_height - target_h) <= 1:
new_width, new_height = target_w, target_h
# Select resampling method based on scaling_method
resampling_map = {
"NEAREST": Image.Resampling.NEAREST,
"BILINEAR": Image.Resampling.BILINEAR,
"BICUBIC": Image.Resampling.BICUBIC,
"LANCZOS": Image.Resampling.LANCZOS
}
resampling_method = resampling_map.get(scaling_method, Image.Resampling.NEAREST)
return image_pil.resize(
(new_width, new_height),
resampling_method
)
def remove_background(self, image, tolerance=30, edge_sensitivity=0.8,
foreground_bias=0.7, color_clusters=8, binary_threshold=128,
edge_refinement=True, dither_handling=True, output_format="RGBA",
output_size="ORIGINAL", scaling_method="NEAREST", auto_adjust=False,
edge_detection_mode="AUTO"):
"""
Main processing function for background removal with error handling.
"""
try:
# Validate input
if image is None or image.shape[0] == 0:
raise ValueError("No input image provided")
# Check image dimensions
if len(image.shape) != 4:
raise ValueError(f"Expected 4D tensor, got {len(image.shape)}D")
# Validate minimum input size (64x64 pixels)
_, height, width, _ = image.shape
if height < 64 or width < 64:
raise ValueError(f"Input image must be at least 64x64 pixels, got {width}x{height}")
# Process with error catching
results, masks = self._process_images(
image=image,
tolerance=tolerance,
edge_sensitivity=edge_sensitivity,
foreground_bias=foreground_bias,
color_clusters=color_clusters,
edge_refinement=edge_refinement,
dither_handling=dither_handling,
binary_threshold=binary_threshold,
output_format=output_format,
output_size=output_size,
scaling_method=scaling_method,
auto_adjust=auto_adjust,
edge_detection_mode=edge_detection_mode
)
return (results, masks)
except cv2.error as e:
raise RuntimeError(f"OpenCV processing error: {str(e)}")
except MemoryError:
raise RuntimeError("Insufficient memory for processing. Try reducing batch size.")
except Exception as e:
raise RuntimeError(f"Background removal failed: {str(e)}")
def _process_images(self, image, tolerance=30, edge_sensitivity=0.8,
foreground_bias=0.7, color_clusters=8, binary_threshold=128,
edge_refinement=True, dither_handling=True, output_format="RGBA",
output_size="ORIGINAL", scaling_method="NEAREST", auto_adjust=False,
edge_detection_mode="AUTO"):
"""
Internal method for processing images without error handling wrapper.
"""
# Convert from ComfyUI tensor format to numpy
batch_size, height, width, channels = image.shape
results = []
masks = []
for i in range(batch_size):
# Convert single image to numpy array
img_np = (image[i].cpu().numpy() * 255).astype(np.uint8)
# Initialize processor with parameters
try:
from .background_remover import EnhancedPixelArtProcessor
except ImportError:
# Fallback for testing outside package structure
from background_remover import EnhancedPixelArtProcessor
# Determine dither handling based on edge detection mode
effective_dither_handling = dither_handling
if edge_detection_mode == "AUTO":
# Use content detection for AUTO mode
temp_processor = EnhancedPixelArtProcessor()
is_pixel_art = temp_processor._detect_pixel_art_characteristics(img_np)
effective_dither_handling = is_pixel_art
elif edge_detection_mode == "PIXEL_ART":
effective_dither_handling = True
elif edge_detection_mode == "PHOTOGRAPHIC":
effective_dither_handling = False
processor = EnhancedPixelArtProcessor(
tolerance=tolerance,
edge_sensitivity=edge_sensitivity,
color_clusters=color_clusters,
foreground_bias=foreground_bias,
edge_refinement=edge_refinement,
dither_handling=effective_dither_handling,
binary_threshold=binary_threshold
)
# Auto-adjust parameters if enabled
if auto_adjust:
adjustments = processor.auto_adjust_parameters(img_np)
if adjustments:
# Apply adjustments
if 'tolerance' in adjustments:
processor.tolerance = adjustments['tolerance']
if 'edge_sensitivity' in adjustments:
processor.edge_sensitivity = adjustments['edge_sensitivity']
if 'foreground_bias' in adjustments:
processor.foreground_bias = adjustments['foreground_bias']
# Process image
rgba_result = processor.remove_background_advanced(img_np)
# Apply scaling if requested
if output_size != "ORIGINAL":
# Parse target dimensions
target_dimensions = self.parse_output_size(output_size)
if target_dimensions is not None:
# Convert to PIL Image for scaling
rgba_pil = Image.fromarray(rgba_result, 'RGBA')
# Apply intelligent scaling with specified method
rgba_scaled = self.intelligent_scale(rgba_pil, target_dimensions, scaling_method)
# Convert back to numpy
rgba_result = np.array(rgba_scaled)
# Extract alpha channel as mask (invert: 0=transparent, 255=opaque)
alpha_channel = rgba_result[:, :, 3]
masks.append(alpha_channel)
# Handle output format
if output_format == "RGBA":
results.append(rgba_result)
else: # RGB_WITH_MASK
rgb_result = rgba_result[:, :, :3]
results.append(rgb_result)
# Convert back to ComfyUI tensor format
if output_format == "RGBA":
# For RGBA, maintain 4 channels
result_tensor = torch.from_numpy(np.array(results)).float() / 255.0
else:
# For RGB_WITH_MASK, use 3 channels
result_tensor = torch.from_numpy(np.array(results)).float() / 255.0
mask_tensor = torch.from_numpy(np.array(masks)).float() / 255.0
return result_tensor, mask_tensor
class TransparencyBackgroundRemoverBatch:
"""
Batch processing version with additional options and auto-adjustment.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"tolerance": ("INT", {
"default": 30,
"min": 0,
"max": 255,
"step": 1,
"display": "number",
"tooltip": "Base color similarity threshold for background detection (0-255)"
}),
"edge_sensitivity": ("FLOAT", {
"default": 0.8,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"display": "slider",
"tooltip": "Base edge detection sensitivity (0-1)"
}),
"auto_adjust": ("BOOLEAN", {
"default": True,
"tooltip": "Automatically adjust parameters based on image content"
}),
"foreground_bias": ("FLOAT", {
"default": 0.7,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"display": "slider",
"tooltip": "Bias towards foreground preservation (0-1)"
}),
"color_clusters": ("INT", {
"default": 8,
"min": 2,
"max": 20,
"step": 1,
"display": "number",
"tooltip": "Number of color clusters for background detection"
}),
},
"optional": {
"edge_refinement": ("BOOLEAN", {
"default": True,
"tooltip": "Apply edge refinement post-processing"
}),
"dither_handling": ("BOOLEAN", {
"default": True,
"tooltip": "Enable dithered pattern detection and handling"
}),
"binary_threshold": ("INT", {
"default": 128,
"min": 0,
"max": 255,
"step": 1,
"display": "number",
"tooltip": "Threshold for binary alpha mask (0-255)"
}),
"progress_reporting": ("BOOLEAN", {
"default": True,
"tooltip": "Generate detailed processing report"
}),
}
}
RETURN_TYPES = ("IMAGE", "MASK", "STRING")
RETURN_NAMES = ("images", "masks", "report")
FUNCTION = "batch_remove_background"
CATEGORY = "image/processing"
def batch_remove_background(self, images, tolerance=30, edge_sensitivity=0.8,
auto_adjust=True, foreground_bias=0.7, color_clusters=8,
edge_refinement=True, dither_handling=True, binary_threshold=128,
progress_reporting=True):
"""
Batch process multiple images with optional auto-adjustment.
Returns:
tuple: A tuple containing:
- result_tensor (torch.Tensor): RGBA images with transparent backgrounds (batch, height, width, 4)
- mask_tensor (torch.Tensor): Binary alpha masks (batch, height, width, 1)
- summary_report (str): Detailed processing report with stats and any errors
"""
try:
if images is None or images.shape[0] == 0:
raise ValueError("No input images provided")
batch_size, height, width, channels = images.shape
# Validate input dimensions
if len(images.shape) != 4:
raise ValueError(f"Expected 4D tensor (batch, height, width, channels), got {len(images.shape)}D")
if channels not in [3, 4]:
raise ValueError(f"Expected 3 or 4 channels (RGB or RGBA), got {channels}")
# Validate minimum input size for all images
if height < 64 or width < 64:
raise ValueError(f"All input images must be at least 64x64 pixels, got {width}x{height}")
results = []
masks = []
reports = []
processing_stats = {
'total_images': batch_size,
'successful': 0,
'failed': 0,
'auto_adjustments': 0,
'avg_processing_time': 0.0,
'adjustments_made': []
}
import time
total_processing_time = 0.0
for i in range(batch_size):
start_time = time.time()
try:
# Convert single image to numpy array
img_np = (images[i].cpu().numpy() * 255).astype(np.uint8)
# Initialize processor with base parameters
try:
from .background_remover import EnhancedPixelArtProcessor
except ImportError:
# Fallback for testing outside package structure
from background_remover import EnhancedPixelArtProcessor
processor = EnhancedPixelArtProcessor(
tolerance=tolerance,
edge_sensitivity=edge_sensitivity,
color_clusters=color_clusters,
foreground_bias=foreground_bias,
edge_refinement=edge_refinement,
dither_handling=dither_handling,
binary_threshold=binary_threshold
)
# Auto-adjust parameters if enabled
adjustments = {}
if auto_adjust:
adjustments = processor.auto_adjust_parameters(img_np)
if adjustments:
processing_stats['auto_adjustments'] += 1
processing_stats['adjustments_made'].append({
'image_index': i,
'adjustments': adjustments
})
# Apply adjustments
if 'tolerance' in adjustments:
processor.tolerance = adjustments['tolerance']
if 'edge_sensitivity' in adjustments:
processor.edge_sensitivity = adjustments['edge_sensitivity']
if 'foreground_bias' in adjustments:
processor.foreground_bias = adjustments['foreground_bias']
# Process image
rgba_result = processor.remove_background_advanced(img_np)
# Extract alpha channel as mask
alpha_channel = rgba_result[:, :, 3]
masks.append(alpha_channel)
results.append(rgba_result)
processing_stats['successful'] += 1
processing_time = time.time() - start_time
total_processing_time += processing_time
if progress_reporting:
report = f"Image {i+1}: Processed successfully"
if adjustments:
report += f" (auto-adjusted: {list(adjustments.keys())})"
report += f" in {processing_time:.3f}s"
reports.append(report)
except Exception as e:
processing_stats['failed'] += 1
error_msg = f"Image {i+1}: Failed - {str(e)}"
reports.append(error_msg)
# Create empty result for failed image
empty_result = np.zeros_like(img_np if 'img_np' in locals() else (height, width, 4), dtype=np.uint8)
if len(empty_result.shape) == 3 and empty_result.shape[2] == 3:
empty_rgba = np.zeros((empty_result.shape[0], empty_result.shape[1], 4), dtype=np.uint8)
empty_rgba[:, :, :3] = empty_result
empty_result = empty_rgba
results.append(empty_result)
masks.append(np.zeros((height, width), dtype=np.uint8))
# Calculate statistics
processing_stats['avg_processing_time'] = total_processing_time / batch_size if batch_size > 0 else 0.0
# Convert results to tensors
result_tensor = torch.from_numpy(np.array(results)).float() / 255.0
mask_tensor = torch.from_numpy(np.array(masks)).float() / 255.0
# Generate summary report
summary_report = self._generate_summary_report(processing_stats, reports if progress_reporting else [])
return (result_tensor, mask_tensor, summary_report)
except Exception as e:
error_report = f"Batch processing failed: {str(e)}"
# Return empty tensors in case of complete failure
empty_images = torch.zeros_like(images)
empty_masks = torch.zeros((images.shape[0], images.shape[1], images.shape[2]))
return (empty_images, empty_masks, error_report)
def _generate_summary_report(self, stats, detailed_reports):
"""Generate a summary report of batch processing results."""
lines = [
"=== Batch Processing Report ===",
f"Total Images: {stats['total_images']}",
f"Successful: {stats['successful']}",
f"Failed: {stats['failed']}",
f"Success Rate: {(stats['successful']/stats['total_images']*100):.1f}%" if stats['total_images'] > 0 else "N/A",
f"Auto-adjustments Applied: {stats['auto_adjustments']}",
f"Average Processing Time: {stats['avg_processing_time']:.3f}s per image",
""
]
if stats['adjustments_made']:
lines.append("Auto-adjustment Details:")
for adj in stats['adjustments_made']:
lines.append(f" Image {adj['image_index']+1}: {adj['adjustments']}")
lines.append("")
if detailed_reports:
lines.append("Detailed Processing Log:")
lines.extend(detailed_reports)
return "\n".join(lines)
# Node registration
NODE_CLASS_MAPPINGS = {
"TransparencyBackgroundRemover": TransparencyBackgroundRemover,
"TransparencyBackgroundRemoverBatch": TransparencyBackgroundRemoverBatch,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"TransparencyBackgroundRemover": "Transparency Background Remover",
"TransparencyBackgroundRemoverBatch": "Transparency Background Remover (Batch)",
}