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

169 lines
5.9 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
from .libs.process import compute_chunk_size
# ============================================================
# GEGL-style posterization core
# ============================================================
def _posterize_channel_gegl(x: torch.Tensor, levels: int) -> torch.Tensor:
"""
GEGL-style uniform quantizer.
Equivalent to:
floor(x * (levels - 1) + 0.5) / (levels - 1)
"""
levels = max(2, int(levels))
scale = levels - 1
return torch.floor(x * scale + 0.5) / scale
# ============================================================
# Main operator – improved with batch chunking, alpha preservation,
# error handling, and early exit.
# ============================================================
def exec_operator_color_posterize(
image: torch.Tensor,
levels: int,
mask: Optional[torch.Tensor] = None,
linear: bool = False, # GEGL toggle: operate in linear light
) -> Tuple[torch.Tensor]:
"""
Apply GEGL‑style posterisation to a batch of images.
The input *image* is a float tensor of shape ``(B, H, W, C)`` with
``C == 3`` (RGB) or ``C == 4`` (RGBA). *levels* controls the number
of discrete levels per colour channel (minimum 2). An optional *mask*
tensor of shape ``(H, W)`` (single mask) or ``(B, H, W)`` (per‑image
mask) defines the per‑pixel strength of the effect: ``0.0`` leaves the
pixel unchanged, ``1.0`` applies full posterisation.
When the input contains an alpha channel (``C == 4``), the alpha
channel is **preserved unchanged**; only the RGB channels are
posterised and blended with the mask.
The function validates input shapes, performs an early exit for empty
batches, and processes the batch in GPU‑friendly chunks as determined
by ``compute_chunk_size``. No spatial tiling is performed.
Parameters
----------
image : torch.Tensor
Batch of images (B,H,W,C), float.
levels : int
Number of posterisation levels (2‑256).
mask : torch.Tensor or None
Optional strength mask (H,W) or (B,H,W).
linear : bool
If ``True``, the effect is applied in linear light
(sRGB ↔ linear conversion is used).
Returns
-------
Tuple[torch.Tensor]
A 1‑tuple containing the posterised image batch.
"""
# ---------- input validation ----------
if not isinstance(image, torch.Tensor):
raise TypeError(f"image must be a torch.Tensor, got {type(image)}")
if image.dim() != 4:
raise ValueError(f"image must be 4D (B,H,W,C), got shape {image.shape}")
C = image.shape[-1]
if C not in (1, 3, 4):
raise ValueError(f"Unsupported number of channels: {C}. Only 1, 3, or 4 are supported")
if image.dtype not in (torch.float32, torch.float64, torch.float16):
raise TypeError(f"image dtype must be float, got {image.dtype}")
levels = max(2, int(levels)) # ensure >= 2
if mask is not None:
if not isinstance(mask, torch.Tensor):
raise TypeError(f"mask must be a torch.Tensor or None, got {type(mask)}")
if mask.dim() == 2:
pass # (H,W) will be broadcast
elif mask.dim() == 3:
if mask.shape[0] != image.shape[0]:
raise ValueError(
f"Batch dimension mismatch: image batch {image.shape[0]}, "
f"mask batch {mask.shape[0]}"
)
else:
raise ValueError(f"mask must be 2D (H,W) or 3D (B,H,W), got shape {mask.shape}")
if mask.device != image.device:
raise ValueError("image and mask must reside on the same device")
# ---------- early exit for empty batches ----------
if image.shape[0] == 0:
return (image,)
# ---------- alpha channel handling ----------
has_alpha = (C == 4)
if has_alpha:
rgb = image[..., :3]
alpha = image[..., 3:4] # keep as (B,H,W,1)
else:
rgb = image
alpha = None
B = rgb.shape[0]
# ---------- determine chunk size (VRAM‑aware) ----------
# The helper considers both image and mask sizes.
chunk_size = compute_chunk_size(image, mask)
chunk_size = max(1, min(chunk_size, B)) # clamp to valid range
out_chunks = []
# ---------- chunked processing ----------
for start in range(0, B, chunk_size):
end = min(start + chunk_size, B)
chunk_rgb = rgb[start:end].clone() # clone to avoid in‑place issues
# Prepare mask chunk
chunk_mask = None
if mask is not None:
if mask.ndim == 3: # per‑image masks
chunk_mask = mask[start:end]
else: # single (H,W) mask, reuse for all images
chunk_mask = mask
# ---- posterisation (per‑channel) ----
x = torch.clamp(chunk_rgb, 0.0, 1.0)
if linear:
x = srgb_to_linear_vec(x)
x_p = _posterize_channel_gegl(x, levels)
if linear:
x_p = linear_to_srgb_vec(x_p)
x_p = torch.clamp(x_p, 0.0, 1.0)
# ---- mask blending (only on RGB) ----
if chunk_mask is not None:
# Ensure mask has shape (B',H,W,1) for broadcasting
if chunk_mask.ndim == 2:
chunk_mask = chunk_mask.unsqueeze(0).unsqueeze(-1) # (1,H,W,1)
else:
chunk_mask = chunk_mask.unsqueeze(-1) # (B',H,W,1)
chunk_mask = chunk_mask.to(dtype=x.dtype, device=x.device)
x_p = chunk_rgb * (1.0 - chunk_mask) + x_p * chunk_mask
# ---- reassemble alpha if present ----
if has_alpha:
chunk_out = torch.cat([x_p, alpha[start:end]], dim=-1)
else:
chunk_out = x_p
out_chunks.append(chunk_out)
# ---------- concatenate chunks ----------
final_image = torch.cat(out_chunks, dim=0)
return (final_image,)