Files
MdONeilsl b6a2d1dd79 Add MD Color Mod nodes, operators, and web UI
Introduce a complete GIMP/GEGL-inspired color module for ComfyUI. Adds: node declarations (nodes/*) and matching operator implementations (py/*) for many color/image operations (balance, temperature, hue/chroma, hue/saturation, saturation, exposure, levels, invert, auto adjustments, maps, posterize, desaturate, tone mapping, component extract/mix, etc.). Includes support libs (py/libs: color, process, blur), layer compositing (layers_blending), VRAM‑aware chunking, mask handling and alpha preservation. Adds web UI extension and widgets (web/*), package metadata, dynamic __init__.py loader, README update and .gitignore tweaks.
2026-07-06 08:17:05 -04:00

183 lines
7.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import torch
from typing import Tuple, Optional
from .libs.color import srgb_to_linear_vec, linear_to_srgb_vec, rgb_to_hsv, hsv_to_rgb
from .libs.process import compute_chunk_size
# --------------------------------------------------------------------
# Internal helpers for channel handling and chunked processing
# --------------------------------------------------------------------
def _ensure_channels(t: torch.Tensor, target_channels: int) -> torch.Tensor:
"""Ensure tensor has exactly target_channels by adding/removing dummy dimensions.
Used only for internal shape adjustments; for actual channel handling see below.
"""
if t.ndim == 3 and t.shape[-1] == 1:
# single channel -> expand to target? Not used here.
pass
return t
def _split_rgb_alpha(image: torch.Tensor):
"""Split image into RGB and alpha (if present)."""
if image.shape[-1] == 3:
return image, None
elif image.shape[-1] == 4:
return image[..., :3], image[..., 3:]
else:
raise ValueError(f"Unsupported number of channels: {image.shape[-1]}")
def _combine_rgb_alpha(rgb: torch.Tensor, alpha: Optional[torch.Tensor]) -> torch.Tensor:
"""Recombine RGB and alpha, or return RGB if no alpha."""
if alpha is None:
return rgb
return torch.cat([rgb, alpha], dim=-1)
def _process_chunk(
image_chunk: torch.Tensor,
inv_type: str,
mask_chunk: Optional[torch.Tensor]
) -> torch.Tensor:
"""
Process a single chunk (batch of images) with the chosen inversion type.
Handles RGB/RGBA, mask blending, and preserves alpha.
"""
# Separate RGB and alpha
rgb, alpha = _split_rgb_alpha(image_chunk)
# Apply inversion to RGB channels only
if inv_type == "Value":
inverted_rgb = _exec_operator_color_value_invert_rgb(rgb)
elif inv_type == "Linear":
inverted_rgb = _exec_operator_color_linear_invert_rgb(rgb)
else: # "RGB"
inverted_rgb = 1.0 - rgb
# Apply mask blending if mask is provided
if mask_chunk is not None:
# Ensure mask has proper shape: (B, H, W) or (B, H, W, 1)
if mask_chunk.ndim == 3:
mask_chunk = mask_chunk.unsqueeze(-1) # (B, H, W, 1)
elif mask_chunk.ndim == 4 and mask_chunk.shape[-1] == 1:
pass
else:
raise ValueError(f"Mask must have shape (B,H,W) or (B,H,W,1), got {mask_chunk.shape}")
# Broadcast mask if needed (batch size 1 vs multiple)
if mask_chunk.shape[0] == 1 and image_chunk.shape[0] > 1:
mask_chunk = mask_chunk.expand(image_chunk.shape[0], -1, -1, -1)
elif mask_chunk.shape[0] != image_chunk.shape[0]:
raise ValueError(
f"Mask batch size {mask_chunk.shape[0]} does not match image batch size {image_chunk.shape[0]}"
)
# Blend: output = image * (1 - mask) + inverted * mask
rgb_blended = rgb * (1.0 - mask_chunk) + inverted_rgb * mask_chunk
# Clamp to [0,1] for safety (though operations should keep within bounds)
rgb_blended = torch.clamp(rgb_blended, 0.0, 1.0)
else:
rgb_blended = inverted_rgb
# Recombine with original alpha
return _combine_rgb_alpha(rgb_blended, alpha)
# --------------------------------------------------------------------
# Core inversion functions (unchanged logic, but work on RGB tensors)
# --------------------------------------------------------------------
def _exec_operator_color_linear_invert_rgb(rgb: torch.Tensor) -> torch.Tensor:
"""Linear inversion in RGB space (sRGB → linear → invert → sRGB)."""
linear = srgb_to_linear_vec(rgb)
inv_linear = 1.0 - linear
return linear_to_srgb_vec(inv_linear)
def _exec_operator_color_value_invert_rgb(rgb: torch.Tensor) -> torch.Tensor:
"""Value (luminosity) inversion in HSV space."""
h, s, v = rgb_to_hsv(rgb)
v_inv = 1.0 - v
inverted = hsv_to_rgb(h, s, v_inv)
return torch.clamp(inverted, 0.0, 1.0)
# --------------------------------------------------------------------
# Public entry point
# --------------------------------------------------------------------
def exec_operator_color_invert(
image: torch.Tensor,
inv_type: str,
mask: Optional[torch.Tensor] = None
) -> Tuple[torch.Tensor]:
"""
Invert colours of an image batch, mimicking GIMP's Colors → Invert.
Supports RGB and RGBA input/output; alpha channel is always passed through unchanged.
If a mask is given, the inverted image is blended with the original using the mask
as an alpha factor: output = image * (1 - mask) + inverted * mask. The mask must be
a single-channel tensor with shape (B, H, W) or (B, H, W, 1). Its batch size must
either match the image batch or be 1 (broadcast).
Args:
image: (B, H, W, C) tensor, C = 3 or 4, values in [0, 1].
inv_type: One of "RGB", "Linear", or "Value".
mask: Optional (B, H, W) or (B, H, W, 1) tensor, values in [0, 1].
Returns:
Tuple containing the inverted image tensor with same shape as input.
Raises:
ValueError: for invalid input shapes, types, or values.
"""
# --- Input validation ---
if not isinstance(image, torch.Tensor):
raise TypeError("image must be a torch.Tensor")
if image.ndim != 4:
raise ValueError(f"image must be 4D (B,H,W,C), got {image.ndim}D")
if image.shape[-1] not in (3, 4):
raise ValueError(f"image must have 3 or 4 channels, got {image.shape[-1]}")
if image.min() < 0 or image.max() > 1:
raise ValueError("image values must be in [0, 1]")
if inv_type not in ("RGB", "Linear", "Value"):
raise ValueError(f"inv_type must be 'RGB', 'Linear', or 'Value', got {inv_type}")
if mask is not None:
if not isinstance(mask, torch.Tensor):
raise TypeError("mask must be a torch.Tensor or None")
if mask.ndim not in (3, 4) or (mask.ndim == 4 and mask.shape[-1] != 1):
raise ValueError(f"mask must be (B,H,W) or (B,H,W,1), got {mask.shape}")
if mask.min() < 0 or mask.max() > 1:
raise ValueError("mask values must be in [0, 1]")
# --- Early exit if mask is all zeros ---
if mask is not None and torch.all(mask == 0):
# No inversion applied – return original image (alpha preserved)
return (image,)
# --- Chunked processing ---
batch_size = image.shape[0]
if batch_size == 0:
return (image,)
# Compute optimal chunk size using the provided helper
chunk_size = compute_chunk_size(image, mask)
if chunk_size <= 0:
chunk_size = batch_size # fallback
# Process in chunks
chunks = []
for start in range(0, batch_size, chunk_size):
end = min(start + chunk_size, batch_size)
img_chunk = image[start:end]
mask_chunk = mask[start:end] if mask is not None else None
processed = _process_chunk(img_chunk, inv_type, mask_chunk)
chunks.append(processed)
# Concatenate chunks along batch dimension
result = torch.cat(chunks, dim=0)
return (result,)