Files
limbicnation ba22db3be5 feat: audit fixes, validation, smoke tests, and conflict resolution
- Add Pydantic parameter validation (src/validation.py)
- Add structured logging with structlog fallback
- Add IS_CHANGED, OUTPUT_NODE, DESCRIPTION metadata to all nodes
- Add comprehensive smoke and unit tests
- Fix invert_mask double-application bug
- Fix create_fallback_processor()() double call
- Resolve merge conflicts in .gitignore, grabcut_nodes.py, nodes.py, requirements.txt
- Remove FLOYO_INTEGRATION_GUIDE.md, schemas.py (superseded)
- Update CI: Python 3.10-3.12, pytest smoke tests
2026-04-24 05:18:02 +02:00

973 lines
41 KiB
Python

from __future__ import annotations
import hashlib
from typing import Any, Optional, Tuple
import numpy as np
import structlog
import torch
from PIL import Image
log = structlog.get_logger(__name__)
import os.path as _path # noqa: E402
import sys as _sys # noqa: E402
_parent = _path.dirname(__file__)
if _parent not in _sys.path:
_sys.path.insert(0, _parent)
# Try to import ComfyUI modules
try:
import folder_paths # noqa: F401
import comfy.utils # noqa: F401
COMFY_AVAILABLE = True
except ImportError:
COMFY_AVAILABLE = False
log.info("comfyui_modules_unavailable", mode="standalone")
# Import our GrabCut processor
try:
from .grabcut_remover import GrabCutProcessor, create_fallback_processor, _log_gpu_memory
except ImportError:
from grabcut_remover import GrabCutProcessor, create_fallback_processor, _log_gpu_memory
# Pydantic validation — security-hardened parameter sanitisation before GPU
# execution. Lives in src/validation.py on main with composed models
# (GrabCutParams, ScalingParams, MaskParams, BBoxParams). Falls back to
# None if pydantic isn't installed so nodes still execute without
# validation (calls are gated by `if validate_node_params is not None`).
try:
try:
from .src.validation import validate_node_params, GrabCutParams, MaskParams
except ImportError:
from src.validation import validate_node_params, GrabCutParams, MaskParams
try:
from pydantic import ValidationError
except ImportError:
ValidationError = ValueError # type: ignore[misc,assignment]
except ImportError:
log.warning(
"grabcut_node.validation_unavailable",
hint="pydantic not installed — parameter validation disabled",
)
validate_node_params = GrabCutParams = MaskParams = None
ValidationError = ValueError # type: ignore[misc,assignment]
def _is_changed_hash(image: Any = None, **kwargs) -> str:
"""Deterministic hash over scalar node inputs for ComfyUI caching.
Tensor inputs are summarised by shape/dtype rather than content — the
ComfyUI graph already tracks upstream tensor changes via connections,
so hashing the full tensor contents would just waste cycles.
"""
m = hashlib.sha256()
for key in sorted(kwargs.keys()):
m.update(f"{key}={kwargs[key]!r};".encode())
if image is not None and hasattr(image, "shape"):
m.update(f"image_shape={tuple(image.shape)};image_dtype={getattr(image, 'dtype', None)};".encode())
return m.hexdigest()
# Import shared ScalingMixin base and extend with GrabCut-specific methods
try:
from .scaling import ScalingMixin as _BaseScalingMixin
except ImportError:
from scaling import ScalingMixin as _BaseScalingMixin
class ScalingMixin(_BaseScalingMixin):
"""
Extended ScalingMixin for GrabCut nodes.
Inherits core resize helpers from ``scaling.ScalingMixin`` and adds
GrabCut-specific methods: ``parse_output_size``, ``intelligent_scale``,
and ``_apply_resize``.
"""
@staticmethod
def parse_output_size(size_string: str) -> Optional[Tuple[int, int]]:
"""
Parse output size string to width, height tuple.
Args:
size_string: String like "512x512" or "ORIGINAL" or "custom"
Returns:
Tuple (width, height) or None for ORIGINAL
"""
if size_string == "ORIGINAL":
return None
elif size_string == "custom":
return None # Will be handled with custom_width/custom_height
try:
width, height = size_string.split('x')
return (int(width), int(height))
except ValueError:
raise ValueError(f"Invalid output size format: {size_string}")
@staticmethod
def calculate_scaling_factor(current_size: Tuple[int, int], target_size: Tuple[int, int]) -> float:
"""
Calculate optimal scaling factor while preserving aspect ratio.
Uses the smaller of width/height scale factors to ensure the scaled image
fits within target dimensions without exceeding them.
Args:
current_size: Tuple (width, height) of current image
target_size: Tuple (width, height) of target size
Returns:
Scaling factor that preserves aspect ratio and fits within target_size
"""
current_w, current_h = current_size
target_w, target_h = target_size
scale_w = target_w / current_w
scale_h = target_h / current_h
scale_factor = min(scale_w, scale_h)
return scale_factor
def intelligent_scale(self, image_pil: Image.Image, target_size: Tuple[int, int], scaling_method: str = "NEAREST") -> Image.Image:
"""
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)
if current_size == target_size:
return image_pil
scale_factor = self.calculate_scaling_factor(current_size, target_size)
new_width = int(image_pil.width * scale_factor)
new_height = int(image_pil.height * scale_factor)
resampling_method = self._RESAMPLING_MAP.get(scaling_method.upper(), Image.Resampling.NEAREST)
return image_pil.resize(
(new_width, new_height),
resampling_method
)
def _apply_resize(self, image_np: np.ndarray, output_size: str, scaling_method: str,
custom_width: int, custom_height: int) -> np.ndarray:
"""
Apply resize operation to image array.
Args:
image_np: Image as numpy array (RGBA format)
output_size: Size specification string
scaling_method: Scaling method to use
custom_width: Custom width for 'custom' size option
custom_height: Custom height for 'custom' size option
Returns:
Resized image as numpy array
"""
if output_size == "ORIGINAL":
return image_np
if output_size == "custom":
target_dimensions = (custom_width, custom_height)
else:
target_dimensions = self.parse_output_size(output_size)
if target_dimensions is None:
return image_np
image_pil = Image.fromarray(image_np, 'RGBA')
scaled_pil = self.intelligent_scale(image_pil, target_dimensions, scaling_method)
return np.array(scaled_pil)
class AutoGrabCutRemover(ScalingMixin):
"""
ComfyUI node for automated GrabCut background removal with object detection.
Refines existing background removal or processes raw images.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"object_class": (["auto", "person", "product", "vehicle", "animal", "furniture", "electronics"],),
"confidence_threshold": ("FLOAT", {
"default": 0.5,
"min": 0.3,
"max": 0.9,
"step": 0.05,
"display": "slider",
"tooltip": "Minimum confidence for object detection"
}),
"grabcut_iterations": ("INT", {
"default": 5,
"min": 1,
"max": 10,
"step": 1,
"tooltip": "Number of GrabCut algorithm iterations"
}),
"margin_pixels": ("INT", {
"default": 20,
"min": 0,
"max": 50,
"step": 5,
"tooltip": "Pixel margin around detected object"
}),
"edge_refinement": ("FLOAT", {
"default": 0.7,
"min": 0.0,
"max": 1.0,
"step": 0.1,
"display": "slider",
"tooltip": "Edge refinement strength (0=none, 1=maximum)"
}),
"edge_blur_amount": ("FLOAT", {
"default": 0.0,
"min": 0.0,
"max": 10.0,
"step": 0.1,
"display": "slider",
"tooltip": "Amount of Gaussian blur to apply to mask edges (0=none, 10=maximum)"
}),
"bbox_safety_margin": ("INT", {
"default": 30,
"min": 0,
"max": 100,
"step": 5,
"tooltip": "Extra pixels beyond detected bounding box for safety"
}),
"min_bbox_size": ("INT", {
"default": 64,
"min": 32,
"max": 256,
"step": 16,
"tooltip": "Minimum bounding box dimensions to prevent over-cropping"
}),
"fallback_margin_percent": ("FLOAT", {
"default": 0.20,
"min": 0.10,
"max": 0.50,
"step": 0.05,
"display": "slider",
"tooltip": "Margin percentage for fallback bbox when no object detected"
}),
"binary_threshold": ("INT", {
"default": 200,
"min": 128,
"max": 250,
"step": 10,
"tooltip": "Threshold for binary mask conversion"
}),
"output_size": (["ORIGINAL", "512x512", "1024x1024", "2048x2048", "custom"], {
"default": "ORIGINAL",
"tooltip": "Target output size for the processed image and mask"
}),
"scaling_method": (["NEAREST", "BILINEAR", "BICUBIC", "LANCZOS"], {
"default": "NEAREST",
"tooltip": "Interpolation method for scaling: NEAREST (pixel-perfect), BILINEAR (smooth), BICUBIC (high-quality), LANCZOS (best quality)"
}),
"auto_adjust": ("BOOLEAN", {
"default": False,
"tooltip": "Automatically adjust parameters based on image content analysis"
}),
},
"optional": {
"initial_mask": ("MASK",),
"output_format": (["RGBA", "MASK"], {
"default": "RGBA",
"tooltip": "Output format: RGBA with alpha channel or binary MASK (0=background, 255=foreground)"
}),
"invert_mask": ("BOOLEAN", {
"default": False,
"tooltip": "Invert the output mask (foreground becomes background and vice versa)"
}),
"custom_width": ("INT", {
"default": 512,
"min": 64,
"max": 4096,
"step": 8,
"tooltip": "Custom width (used when output_size is 'custom')"
}),
"custom_height": ("INT", {
"default": 512,
"min": 64,
"max": 4096,
"step": 8,
"tooltip": "Custom height (used when output_size is 'custom')"
}),
"edge_detection_mode": (["AUTO", "PIXEL_ART", "PHOTOGRAPHIC"], {
"default": "AUTO",
"tooltip": "Edge detection optimization: AUTO (detect content type), PIXEL_ART (sharp edges), PHOTOGRAPHIC (smooth edges)"
}),
"invert_mask": ("BOOLEAN", {
"default": False,
"tooltip": "Invert the output alpha/mask (swap foreground and background)"
}),
}
}
RETURN_TYPES = ("IMAGE", "MASK", "STRING", "FLOAT", "STRING", "BBOX_TENSOR")
RETURN_NAMES = ("image", "mask", "bbox_coords", "confidence", "metrics", "bbox_tensor")
FUNCTION = "remove_background"
CATEGORY = "image/processing"
OUTPUT_NODE = False
DESCRIPTION = (
"Automatic object detection (YOLO) + GrabCut refinement. "
"Produces an IMAGE (RGBA or binary MASK), a MASK, bbox coordinate "
"string, confidence score, metrics, and a structured BBOX_TENSOR."
)
@classmethod
def IS_CHANGED(cls, image=None, **kwargs):
return _is_changed_hash(image, **kwargs)
def __init__(self):
"""Initialize the node with GrabCut processor."""
self.processor = None
self._initialize_processor()
def _initialize_processor(self):
"""Initialize the GrabCut processor, with fallback if YOLO unavailable."""
try:
self.processor = GrabCutProcessor()
except Exception as e:
log.warning("grabcut_node.yolo_init_failed", error=str(e),
fallback="FallbackGrabCutProcessor")
self.processor = create_fallback_processor()()
def _map_object_class(self, object_class: str) -> Optional[str]:
"""Map UI object class to YOLO class names."""
mapping = {
"auto": None,
"person": "person",
"product": "bottle", # Generic product detection
"vehicle": "car",
"animal": "dog", # Generic animal detection
"furniture": "chair",
"electronics": "laptop"
}
return mapping.get(object_class, None)
@torch.no_grad()
def remove_background(self, image: torch.Tensor,
initial_mask: Optional[torch.Tensor] = None,
object_class: str = "auto",
confidence_threshold: float = 0.5,
grabcut_iterations: int = 5,
margin_pixels: int = 20,
edge_refinement: float = 0.7,
edge_blur_amount: float = 0.0,
bbox_safety_margin: int = 30,
min_bbox_size: int = 64,
fallback_margin_percent: float = 0.20,
binary_threshold: int = 200,
output_size: str = "ORIGINAL",
scaling_method: str = "NEAREST",
auto_adjust: bool = False,
output_format: str = "RGBA",
invert_mask: bool = False,
custom_width: int = 512,
custom_height: int = 512,
edge_detection_mode: str = "AUTO") -> Tuple:
"""
Process image with automated GrabCut background removal.
Args:
image: Input image tensor from ComfyUI
object_class: Target object class for detection
confidence_threshold: Minimum detection confidence
grabcut_iterations: Number of GrabCut iterations
margin_pixels: Margin around detected object
edge_refinement: Edge refinement strength
binary_threshold: Binary mask threshold
edge_detection_mode: Edge detection optimization mode
initial_mask: Optional initial mask from previous processing
Returns:
Tuple of (processed_image, mask, bbox_string, confidence, metrics)
"""
# --- Pydantic validation: sanitise ALL user params before GPU execution ---
if validate_node_params is not None:
try:
validated = validate_node_params(
iterations=grabcut_iterations,
margin=margin_pixels,
confidence_threshold=confidence_threshold,
scaling_method=scaling_method,
edge_blur_amount=edge_blur_amount,
invert_mask=invert_mask,
edge_refinement_strength=edge_refinement,
bbox_safety_margin=bbox_safety_margin,
min_bbox_size=min_bbox_size,
fallback_margin_percent=fallback_margin_percent,
binary_threshold=binary_threshold,
output_format=output_format,
auto_adjust=auto_adjust,
)
# Use normalised values from Pydantic (e.g. scaling_method lowercased)
grabcut_iterations = validated.iterations
margin_pixels = validated.margin
confidence_threshold = validated.confidence_threshold
scaling_method = validated.scaling_method
edge_blur_amount = validated.edge_blur_amount
invert_mask = validated.invert_mask
edge_refinement = validated.edge_refinement_strength
bbox_safety_margin = validated.bbox_safety_margin
min_bbox_size = validated.min_bbox_size
fallback_margin_percent = validated.fallback_margin_percent
binary_threshold = validated.binary_threshold
output_format = validated.output_format
auto_adjust = validated.auto_adjust
except (ValidationError, ValueError, TypeError) as exc:
log.error("grabcut_node.validation_failed", node="AutoGrabCutRemover", error=str(exc))
raise ValueError(f"[AutoGrabCutRemover] Invalid parameters: {exc}") from exc
log.info("grabcut_node.remove_background.start",
batch_size=image.shape[0] if len(image.shape) == 4 else 1,
output_format=output_format)
if torch.cuda.is_available():
log.debug("gpu_memory.remove_background.start",
allocated_gb=round(torch.cuda.memory_allocated() / 1e9, 3))
# Ensure processor is initialized
if self.processor is None:
self._initialize_processor()
# Update processor parameters
self.processor.confidence_threshold = confidence_threshold
self.processor.iterations = grabcut_iterations
self.processor.margin_pixels = margin_pixels
self.processor.edge_refinement_strength = edge_refinement
self.processor.edge_blur_amount = edge_blur_amount
self.processor.bbox_safety_margin = bbox_safety_margin
self.processor.min_bbox_size = min_bbox_size
self.processor.fallback_margin_percent = fallback_margin_percent
self.processor.binary_threshold = binary_threshold
# Apply edge detection mode optimizations
if edge_detection_mode != "AUTO":
if edge_detection_mode == "PIXEL_ART":
# Optimize for pixel art: precise edges, minimal smoothing
self.processor.edge_refinement_strength = min(0.4, edge_refinement)
self.processor.binary_threshold = max(220, binary_threshold)
self.processor.margin_pixels = max(5, min(15, margin_pixels))
elif edge_detection_mode == "PHOTOGRAPHIC":
# Optimize for photos: smooth edges, more refinement
self.processor.edge_refinement_strength = min(0.9, edge_refinement + 0.2)
self.processor.binary_threshold = max(180, binary_threshold - 20)
self.processor.margin_pixels = max(15, min(35, margin_pixels + 10))
else:
# AUTO mode: detect content type for first image
first_image = image[0] if len(image.shape) == 4 else image
img_np = (first_image.cpu().numpy() * 255).astype(np.uint8)
# Ensure channels last format (H, W, C)
if img_np.shape[0] == 3 or img_np.shape[0] == 4:
img_np = np.transpose(img_np, (1, 2, 0))
# Convert to RGB if needed
if img_np.shape[2] == 4:
img_np = img_np[:, :, :3]
# Detect if pixel art
is_pixel_art = self.processor._detect_pixel_art_characteristics(img_np)
if is_pixel_art:
# Apply pixel art optimizations
self.processor.edge_refinement_strength = min(0.4, edge_refinement)
self.processor.binary_threshold = max(220, binary_threshold)
self.processor.margin_pixels = max(5, min(15, margin_pixels))
else:
# Apply photographic optimizations
self.processor.edge_refinement_strength = min(0.9, edge_refinement + 0.1)
self.processor.binary_threshold = max(180, binary_threshold - 10)
self.processor.margin_pixels = max(10, min(30, margin_pixels + 5))
# Auto-adjust parameters if enabled (applied to first image for batch processing)
if auto_adjust:
# Get first image for analysis
first_image = image[0] if len(image.shape) == 4 else image
# Convert to numpy for analysis
img_np = (first_image.cpu().numpy() * 255).astype(np.uint8)
# Ensure channels last format (H, W, C)
if img_np.shape[0] == 3 or img_np.shape[0] == 4:
img_np = np.transpose(img_np, (1, 2, 0))
# Convert to RGB if needed
if img_np.shape[2] == 4:
img_np = img_np[:, :, :3]
# Get parameter adjustments
adjustments = self.processor.auto_adjust_parameters(img_np)
# Apply adjustments to processor
if adjustments:
for param, value in adjustments.items():
if hasattr(self.processor, param):
setattr(self.processor, param, value)
# Handle batch dimension
if len(image.shape) == 4:
batch_size = image.shape[0]
else:
batch_size = 1
image = image.unsqueeze(0)
# Ensure initial_mask has batch dimension regardless of image path
if initial_mask is not None and len(initial_mask.shape) == 2:
initial_mask = initial_mask.unsqueeze(0)
processed_images = []
masks = []
all_bboxes = []
all_confidences = []
all_metrics = []
bbox_metadata = torch.zeros((batch_size, 6), dtype=torch.float32)
for i in range(batch_size):
_log_gpu_memory(f"batch_item_{i}.start")
try:
# Convert from ComfyUI tensor format to numpy
img_tensor = image[i]
# Validate input tensor — append fallback on failure to keep
# batch alignment with bbox_metadata and avoid empty torch.stack
if len(img_tensor.shape) != 3:
log.warning("grabcut_node.unexpected_shape", shape=list(img_tensor.shape))
# image[i] from a 4D tensor is always 3D — direct index is safe
h = img_tensor.shape[-3]
w = img_tensor.shape[-2]
processed_images.append(torch.zeros((h, w, 4 if output_format == "RGBA" else 1), dtype=torch.float32))
masks.append(torch.zeros((h, w), dtype=torch.float32))
all_bboxes.append("(0,0,0,0)")
all_confidences.append(0.0)
all_metrics.append(f"Batch {i+1}/{batch_size}: Invalid tensor shape")
continue
img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8)
# Ensure channels last format (H, W, C)
if img_np.shape[0] == 3 or img_np.shape[0] == 4:
img_np = np.transpose(img_np, (1, 2, 0))
# Final validation
if len(img_np.shape) != 3 or img_np.shape[2] not in [3, 4]:
log.warning("grabcut_node.invalid_shape", shape=list(img_np.shape))
h = img_np.shape[0]
w = img_np.shape[1]
processed_images.append(torch.zeros((h, w, 4 if output_format == "RGBA" else 1), dtype=torch.float32))
masks.append(torch.zeros((h, w), dtype=torch.float32))
all_bboxes.append("(0,0,0,0)")
all_confidences.append(0.0)
all_metrics.append(f"Batch {i+1}/{batch_size}: Invalid image shape")
continue
# Convert to RGB if needed
if img_np.shape[2] == 4:
img_np = img_np[:, :, :3]
# Map object class
target_class = self._map_object_class(object_class)
# Process with GrabCut
if initial_mask is not None and i < initial_mask.shape[0]:
# Use initial mask if provided
mask_np = (initial_mask[i].cpu().numpy() * 255).astype(np.uint8)
if len(mask_np.shape) == 3:
mask_np = mask_np.squeeze()
result = self.processor.process_with_initial_mask(img_np, mask_np, target_class)
else:
# Process without initial mask
result = self.processor.process_with_grabcut(img_np, target_class)
if result['success']:
# Convert RGBA result back to tensor format
rgba = result['rgba_image']
# Apply resize if requested
rgba = self._apply_resize(rgba, output_size, scaling_method, custom_width, custom_height)
# Invert alpha channel if requested — applied to the
# RGBA buffer so both the RGBA IMAGE output and the
# derived MASK remain consistent.
if invert_mask:
rgba = rgba.copy()
rgba[:, :, 3] = 255 - rgba[:, :, 3]
# Separate RGB and alpha
alpha = rgba[:, :, 3].astype(np.float32) / 255.0
if output_format == "RGBA":
# For RGBA output: preserve transparency, don't premultiply alpha
# Create 4-channel RGBA tensor
rgba_tensor = torch.from_numpy(rgba.astype(np.float32) / 255.0).to(dtype=torch.float32)
processed_images.append(rgba_tensor)
else: # output_format == "MASK"
# For MASK output: return binary mask as primary output
# Convert alpha to binary mask (0 or 255)
binary_mask = (alpha > 0.5).astype(np.float32)
mask_tensor = torch.from_numpy(binary_mask).to(dtype=torch.float32)
processed_images.append(mask_tensor.unsqueeze(-1)) # Add channel dimension
# Alpha tensor for mask output (always provided)
alpha_tensor = torch.from_numpy(alpha).to(dtype=torch.float32)
masks.append(alpha_tensor)
# Format bbox and metrics
if result['bbox']:
x1, y1, x2, y2 = result['bbox']
bbox_str = f"({x1},{y1},{x2},{y2})"
all_bboxes.append(bbox_str)
else:
all_bboxes.append("(0,0,0,0)")
all_confidences.append(result['confidence'])
# Populate batch metadata tensor [B, 6] = (x1, y1, w, h, confidence, detected)
if result['bbox']:
bx1, by1, bx2, by2 = result['bbox']
bw = bx2 - bx1
bh = by2 - by1
bbox_metadata[i] = torch.tensor(
[bx1, by1, bw, bh, result['confidence'], 1.0],
dtype=torch.float32
)
metrics = (f"Batch {i+1}/{batch_size}: "
f"Time={result['processing_time_ms']}ms, "
f"Conf={result['confidence']:.2f}, "
f"Class={object_class}")
all_metrics.append(metrics)
else:
# Fallback: handle based on output format
if output_format == "RGBA":
# Return original RGB with full alpha channel
h, w = img_np.shape[:2]
rgba_fallback = np.zeros((h, w, 4), dtype=np.float32)
rgba_fallback[:, :, :3] = img_np.astype(np.float32) / 255.0
rgba_fallback[:, :, 3] = 1.0 # Full opacity
rgb_tensor = torch.from_numpy(rgba_fallback).to(dtype=torch.float32)
else: # output_format == "MASK"
# Return full foreground mask
alpha_fallback = np.ones((img_np.shape[0], img_np.shape[1], 1), dtype=np.float32)
rgb_tensor = torch.from_numpy(alpha_fallback).to(dtype=torch.float32)
alpha_tensor = torch.ones((img_np.shape[0], img_np.shape[1]), dtype=torch.float32)
processed_images.append(rgb_tensor)
masks.append(alpha_tensor)
all_bboxes.append("(0,0,0,0)")
all_confidences.append(0.0)
all_metrics.append(f"Batch {i+1}/{batch_size}: Processing failed")
except Exception as e:
log.error("grabcut_node.batch_error", item=i, error=str(e))
# Derive h,w from original input tensor to avoid shape mismatch
img_shape = image[i].shape
h = img_shape[-3] if len(img_shape) >= 3 else 512
w = img_shape[-2] if len(img_shape) >= 2 else 512
if output_format == "RGBA":
rgb_tensor = torch.zeros((h, w, 4), dtype=torch.float32)
else:
rgb_tensor = torch.zeros((h, w, 1), dtype=torch.float32)
alpha_tensor = torch.zeros((h, w), dtype=torch.float32)
processed_images.append(rgb_tensor)
masks.append(alpha_tensor)
all_bboxes.append("(0,0,0,0)")
all_confidences.append(0.0)
all_metrics.append(f"Batch {i+1}/{batch_size}: Error occurred")
finally:
if torch.cuda.is_available():
torch.cuda.empty_cache()
_log_gpu_memory(f"batch_item_{i}.end")
output_image = torch.stack(processed_images)
output_mask = torch.stack(masks)
bbox_output = " | ".join(all_bboxes)
confidence_output = float(np.mean(all_confidences))
metrics_output = "\n".join(all_metrics)
_log_gpu_memory("remove_background.end")
log.info("grabcut_node.remove_background.done",
batch_size=batch_size, mean_confidence=round(confidence_output, 3))
return (output_image, output_mask, bbox_output, confidence_output, metrics_output, bbox_metadata)
class GrabCutRefinement(ScalingMixin):
"""
ComfyUI node for refining existing masks using GrabCut.
Takes an image and mask, refines the mask with GrabCut.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"mask": ("MASK",),
"grabcut_iterations": ("INT", {
"default": 3,
"min": 1,
"max": 10,
"step": 1,
"tooltip": "Number of GrabCut refinement iterations"
}),
"edge_refinement": ("FLOAT", {
"default": 0.5,
"min": 0.0,
"max": 1.0,
"step": 0.1,
"display": "slider",
"tooltip": "Edge smoothing strength"
}),
"edge_blur_amount": ("FLOAT", {
"default": 0.0,
"min": 0.0,
"max": 10.0,
"step": 0.1,
"display": "slider",
"tooltip": "Amount of Gaussian blur to apply to mask edges (0=none, 10=maximum)"
}),
"expand_margin": ("INT", {
"default": 10,
"min": 0,
"max": 50,
"step": 5,
"tooltip": "Pixels to expand mask boundary for refinement"
}),
"bbox_safety_margin": ("INT", {
"default": 20,
"min": 0,
"max": 100,
"step": 5,
"tooltip": "Extra pixels beyond detected bounding box for safety"
}),
"min_bbox_size": ("INT", {
"default": 64,
"min": 32,
"max": 256,
"step": 16,
"tooltip": "Minimum bounding box dimensions to prevent over-cropping"
}),
"output_size": (["ORIGINAL", "512x512", "1024x1024", "2048x2048", "custom"], {
"default": "ORIGINAL",
"tooltip": "Target output size for the refined image and mask"
}),
"scaling_method": (["NEAREST", "BILINEAR", "BICUBIC", "LANCZOS"], {
"default": "NEAREST",
"tooltip": "Interpolation method for scaling"
}),
},
"optional": {
"custom_width": ("INT", {
"default": 512,
"min": 64,
"max": 4096,
"step": 8,
"tooltip": "Custom width (used when output_size is 'custom')"
}),
"custom_height": ("INT", {
"default": 512,
"min": 64,
"max": 4096,
"step": 8,
"tooltip": "Custom height (used when output_size is 'custom')"
}),
"invert_mask": ("BOOLEAN", {
"default": False,
"tooltip": "Invert the output mask (foreground becomes background and vice versa)"
}),
}
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "refined_mask")
FUNCTION = "refine_mask"
CATEGORY = "image/processing"
OUTPUT_NODE = False
DESCRIPTION = (
"Refine an existing mask with GrabCut. Takes an IMAGE and a MASK "
"and returns an image + cleaner mask, with optional resize and "
"alpha inversion."
)
@classmethod
def IS_CHANGED(cls, image=None, **kwargs):
return _is_changed_hash(image, **kwargs)
def __init__(self):
"""Initialize the refinement node."""
self.processor = None
self._initialize_processor()
def _initialize_processor(self):
"""Initialize processor for refinement."""
try:
self.processor = GrabCutProcessor(iterations=3)
except Exception:
self.processor = create_fallback_processor(iterations=3)
@torch.no_grad()
def refine_mask(self, image: torch.Tensor, mask: torch.Tensor,
grabcut_iterations: int = 3,
edge_refinement: float = 0.5,
edge_blur_amount: float = 0.0,
expand_margin: int = 10,
bbox_safety_margin: int = 20,
min_bbox_size: int = 64,
output_size: str = "ORIGINAL",
scaling_method: str = "NEAREST",
invert_mask: bool = False,
custom_width: int = 512,
custom_height: int = 512) -> Tuple:
"""
Refine existing mask using GrabCut.
Args:
image: Input image tensor
mask: Initial mask to refine
grabcut_iterations: Number of iterations
edge_refinement: Edge smoothing strength
expand_margin: Margin expansion pixels
Returns:
Tuple of (image_with_refined_alpha, refined_mask)
"""
# --- Pydantic validation: sanitise params before GPU execution ---
if GrabCutParams is not None and MaskParams is not None:
try:
validated_gc = GrabCutParams(
iterations=grabcut_iterations,
margin=expand_margin,
)
validated_mask = MaskParams(
edge_blur_amount=edge_blur_amount,
invert_mask=invert_mask,
edge_refinement_strength=edge_refinement,
)
# Use validated/clamped values
grabcut_iterations = validated_gc.iterations
expand_margin = validated_gc.margin
edge_blur_amount = validated_mask.edge_blur_amount
invert_mask = validated_mask.invert_mask
edge_refinement = validated_mask.edge_refinement_strength
except Exception as exc:
log.error("grabcut_node.validation_failed",
node="GrabCutRefinement", error=str(exc))
raise ValueError(f"[GrabCutRefinement] Invalid parameters: {exc}") from exc
log.info("grabcut_node.refine_mask.start",
batch_size=image.shape[0] if len(image.shape) == 4 else 1)
_log_gpu_memory("refine_mask.start")
if self.processor is None:
self._initialize_processor()
# Update parameters
self.processor.iterations = grabcut_iterations
self.processor.edge_refinement_strength = edge_refinement
self.processor.edge_blur_amount = edge_blur_amount
self.processor.margin_pixels = expand_margin
self.processor.bbox_safety_margin = bbox_safety_margin
self.processor.min_bbox_size = min_bbox_size
self.processor.fallback_margin_percent = 0.15
# Handle batch
if len(image.shape) == 4:
batch_size = image.shape[0]
else:
batch_size = 1
image = image.unsqueeze(0)
# Ensure mask has batch dimension regardless of image path
if len(mask.shape) == 2:
mask = mask.unsqueeze(0)
refined_images = []
refined_masks = []
for i in range(batch_size):
_log_gpu_memory(f"refine_batch_{i}.start")
# Convert once at the top so every branch (success, failure,
# exception) emits numpy buffers at the same resolution — the
# pre-merge loop appended unresized image[i]/mask[i] on failure
# which broke torch.stack when output_size != "ORIGINAL".
img_np = (image[i].cpu().numpy() * 255).astype(np.uint8)
if img_np.ndim == 3 and img_np.shape[0] in (3, 4) and img_np.shape[-1] not in (3, 4):
img_np = np.transpose(img_np, (1, 2, 0))
img_rgb = img_np[:, :, :3] if img_np.ndim == 3 else img_np
mask_np = (mask[i].cpu().numpy() * 255).astype(np.uint8)
if mask_np.ndim == 3:
mask_np = mask_np.squeeze()
try:
result = self.processor.process_with_initial_mask(img_rgb, mask_np, None)
if result.get('success'):
rgba = result['rgba_image']
else:
# Processor reported failure — fall back to the original
# image composited with the original mask as alpha.
rgba = np.dstack([img_rgb, mask_np]).astype(np.uint8)
except Exception as exc:
log.error("grabcut_node.refine_error", item=i, error=str(exc), exc_info=True)
rgba = np.dstack([img_rgb, mask_np]).astype(np.uint8)
finally:
if torch.cuda.is_available():
torch.cuda.empty_cache()
_log_gpu_memory(f"refine_batch_{i}.end")
# Apply the same resize to every path so torch.stack sees a
# uniform shape across the batch.
rgba = self._apply_resize(rgba, output_size, scaling_method, custom_width, custom_height)
rgb = rgba[:, :, :3].astype(np.float32) / 255.0
alpha = rgba[:, :, 3].astype(np.float32) / 255.0
if invert_mask:
alpha = 1.0 - alpha
refined_images.append(torch.from_numpy(rgb).to(dtype=torch.float32))
refined_masks.append(torch.from_numpy(alpha).to(dtype=torch.float32))
output_image = torch.stack(refined_images)
output_mask = torch.stack(refined_masks)
_log_gpu_memory("refine_mask.end")
log.info("grabcut_node.refine_mask.done", batch_size=batch_size)
return (output_image, output_mask)
# Node mappings for ComfyUI
NODE_CLASS_MAPPINGS = {
"AutoGrabCutRemover": AutoGrabCutRemover,
"GrabCutRefinement": GrabCutRefinement,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AutoGrabCutRemover": "Auto GrabCut Background Remover",
"GrabCutRefinement": "GrabCut Mask Refinement",
}