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

186 lines
6.6 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
import logging
from .libs.color import rgb_to_hsv, hsv_to_rgb, rgb_to_lch, lch_to_rgb, rgb_to_yuv, yuv_to_rgb
from .libs.process import compute_chunk_size
logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------
# Core saturation algorithm – processes a single batch on one device
# ----------------------------------------------------------------------
def _saturation_core(
image: torch.Tensor, # (B, H, W, C) C = 3 or 4
scale: float,
color_space: str,
mask: Optional[torch.Tensor] # (B, H, W, C) or None
) -> torch.Tensor:
"""
Apply saturation scaling to an image batch (RGB or RGBA).
Handles exactly one device; no chunking, no fallback.
"""
# Early exit when no change is requested
if scale == 1.0:
return image
# Clamp input to valid colour range
img = image.clamp(0.0, 1.0)
# Separate alpha if present
if img.shape[-1] == 4:
alpha = img[..., 3:4]
rgb = img[..., :3]
else:
alpha = None
rgb = img
# ----- colour space conversion & scaling -----
if color_space == "Native":
h, s, v = rgb_to_hsv(rgb)
s = torch.clamp(s * scale, 0.0, 1.0)
result_rgb = hsv_to_rgb(h, s, v)
elif color_space == "CIE Lab/Lch":
l, c, h = rgb_to_lch(rgb)
c = c * scale
result_rgb = lch_to_rgb(l, c, h)
else: # CIE Yuv
y, u, v = rgb_to_yuv(rgb)
u = u * scale
v = v * scale
result_rgb = yuv_to_rgb(y, u, v)
result_rgb = torch.clamp(result_rgb, 0.0, 1.0)
# Reassemble RGBA if needed
if alpha is not None:
result = torch.cat([result_rgb, alpha], dim=-1)
else:
result = result_rgb
# Mask blending (applied to all channels, including alpha)
if mask is not None:
result = img * (1.0 - mask) + result * mask
return result
# ----------------------------------------------------------------------
# Public entry point – batch, device, VRAM management, error handling
# ----------------------------------------------------------------------
def exec_operator_color_saturation(
image: torch.Tensor,
scale: float,
color_space: str,
mask: Optional[torch.Tensor] = None
) -> Tuple[torch.Tensor]:
"""
Adjust the saturation of an image or batch of images.
Parameters
----------
image : torch.Tensor
Input image(s) in RGB or RGBA format.
Shape: (H, W, 3) or (H, W, 4) for a single image,
(B, H, W, 3) or (B, H, W, 4) for a batch.
scale : float
Saturation multiplier. 1.0 = no change, <1 reduces saturation,
>1 increases saturation.
color_space : str
Colour space in which saturation is scaled.
One of ``"Native"``, ``"CIE Lab/Lch"``, ``"CIE Yuv"``.
mask : torch.Tensor, optional
Optional mask defining where the adjustment is applied.
Shape must match the image batch size and spatial dimensions.
(B, H, W) or (B, 1, H, W) or (B, H, W, 1).
Returns
-------
Tuple[torch.Tensor]
A tuple containing the saturation‑adjusted image(s) in the same
format and shape as the input.
"""
# --- validate input shapes and types ---
if not isinstance(image, torch.Tensor):
raise TypeError("image must be a torch.Tensor")
if image.dim() not in (3, 4):
raise ValueError(f"image must be 3D (H,W,C) or 4D (B,H,W,C), got {image.dim()}D")
if image.shape[-1] not in (3, 4):
raise ValueError(f"image channels must be 3 or 4, got {image.shape[-1]}")
if not isinstance(scale, (int, float)):
raise TypeError("scale must be a float")
if color_space not in ("Native", "CIE Lab/Lch", "CIE Yuv"):
raise ValueError(f"unknown color_space '{color_space}'")
# Normalise to batch format (B, H, W, C)
single_input = image.dim() == 3
if single_input:
image = image.unsqueeze(0) # (1, H, W, C)
if mask is not None:
mask = mask.unsqueeze(0) # (1, …) whatever dim
batch_size, h, w, channels = image.shape
device = image.device
# --- mask handling (ensure (B, H, W, C) compatible) ---
if mask is not None:
mask = mask.to(device)
# bring to (B, H, W, 1)
if mask.dim() == 2: # (H, W)
mask = mask.unsqueeze(0).unsqueeze(-1)
elif mask.dim() == 3: # (B, H, W)
mask = mask.unsqueeze(-1)
elif mask.dim() == 4:
if mask.shape[1] == 1: # (B, 1, H, W)
mask = mask.permute(0, 2, 3, 1) # (B, H, W, 1)
elif mask.shape[-1] != 1:
raise ValueError("mask must have a single channel")
else:
raise ValueError("mask must be 2D, 3D or 4D")
if mask.shape[0] != batch_size:
raise ValueError(f"batch size mismatch: image {batch_size}, mask {mask.shape[0]}")
# Expand mask to full channel dimension
mask = mask.expand(-1, -1, -1, channels)
# --- VRAM optimised chunked processing (GPU only) ---
VRAM_CHUNK_SIZE = compute_chunk_size(image) # process 4 images at once on GPU to limit peak memory
def _process_chunked(img_batch, msk_batch):
"""Process a whole batch, potentially in chunks."""
if device.type == 'cuda' and batch_size > VRAM_CHUNK_SIZE:
results = []
for i in range(0, batch_size, VRAM_CHUNK_SIZE):
end = min(i + VRAM_CHUNK_SIZE, batch_size)
sub_img = img_batch[i:end]
sub_msk = msk_batch[i:end] if msk_batch is not None else None
results.append(_saturation_core(sub_img, scale, color_space, sub_msk))
# free GPU memory peaks
torch.cuda.empty_cache()
return torch.cat(results, dim=0)
else:
return _saturation_core(img_batch, scale, color_space, msk_batch)
# --- execute with GPU OOM fallback ---
try:
result = _process_chunked(image, mask)
except RuntimeError as e:
if 'out of memory' in str(e) and device.type == 'cuda':
logger.warning("CUDA out of memory – falling back to CPU")
torch.cuda.empty_cache()
cpu_image = image.cpu()
cpu_mask = mask.cpu() if mask is not None else None
result_cpu = _saturation_core(cpu_image, scale, color_space, cpu_mask)
result = result_cpu.to(device)
else:
raise
# Remove batch dimension if input was a single image
if single_input:
result = result.squeeze(0)
return (result,)