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.
This commit is contained in:
MdONeilsl
2026-07-06 08:17:05 -04:00
parent db644e55b4
commit b6a2d1dd79
45 changed files with 7347 additions and 3 deletions
+4 -1
View File
@@ -178,4 +178,7 @@ cython_debug/
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
.cursorindexingignore
note.txt
tmp
web/widgets
+13 -2
View File
@@ -1,2 +1,13 @@
# ComfyUI-MdColorMod
GIMP color mod in confyUI
# GIMP/GEGL Color Mod for ComfyUI
A standardized collection of color operation nodes designed for seamless integration with ComfyUI, leveraging GIMP’s powerful GEGL framework.
---
## ✅ Features
- **All Nodes Support Masks**: Each node accepts a mask input for precise control over affected areas.
- **Batch Processing**: Process multiple images and their corresponding masks simultaneously.
- **Full RGB/RGBA Support**: Input and output support both RGB and RGBA format.
---
+32
View File
@@ -0,0 +1,32 @@
import importlib
import pkgutil
import os
# Tells ComfyUI to load the JS files from the /web folder
WEB_DIRECTORY = "./web"
# Dynamically collect all node mappings from the nodes/ folder
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
_current_dir = os.path.dirname(__file__)
_nodes_dir = os.path.join(_current_dir, "nodes")
for module_info in pkgutil.iter_modules([_nodes_dir]):
module_name = module_info.name
module = importlib.import_module(f".nodes.{module_name}", package=__package__)
if hasattr(module, "NODE_CLASS_MAPPINGS"):
for key, value in module.NODE_CLASS_MAPPINGS.items():
if key in NODE_CLASS_MAPPINGS:
print(f"Warning: duplicate node key '{key}' from {module_name}")
NODE_CLASS_MAPPINGS[key] = value
if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS"):
NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS)
# Clean up helper variables (optional, but keeps namespace tidy)
del _current_dir, _nodes_dir, module_info, module_name, module
# Public API – yes, you should keep __all__ so users know what's available
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
View File
+302
View File
@@ -0,0 +1,302 @@
from ..py.libs.color import pack_rgb
module_cat = "md/color"
MAX_RGB_INT = 0xFFFFFF
# =============================================================================
# RGB
# =============================================================================
class mdPrimitiveColorRGB:
@classmethod
def INPUT_TYPES(cls) -> dict:
return {
"required": {
"Red": ("FLOAT", {"default": 0, "min": 0, "max": 255, "step": 1}),
"Green": ("FLOAT", {"default": 0, "min": 0, "max": 255, "step": 1}),
"Blue": ("FLOAT", {"default": 0, "min": 0, "max": 255, "step": 1}),
},
}
RETURN_TYPES = ("INT", "STRING")
RETURN_NAMES = ("int", "HEX")
FUNCTION = "exec"
CATEGORY = module_cat
def exec(self, Red, Green, Blue):
# Round and clamp
r = max(0, min(255, int(round(Red))))
g = max(0, min(255, int(round(Green))))
b = max(0, min(255, int(round(Blue))))
packed = (r << 16) | (g << 8) | b
hex_str = f"#{r:02X}{g:02X}{b:02X}"
return (packed, hex_str)
# =============================================================================
# HSV
# =============================================================================
class mdPrimitiveColorHSV:
@classmethod
def INPUT_TYPES(cls) -> dict:
return {
"required": {
"Hue": ("FLOAT", {"default": 0, "min": 0, "max": 360, "step": 0.1}),
"Saturation": ("FLOAT", {"default": 0, "min": 0, "max": 100, "step": 0.1}),
"Value": ("FLOAT", {"default": 0, "min": 0, "max": 100, "step": 0.1}),
},
}
RETURN_TYPES = ("INT", "STRING")
RETURN_NAMES = ("int", "HEX")
FUNCTION = "exec"
CATEGORY = module_cat
def exec(self, Hue, Saturation, Value):
h = Hue / 360.0
s = max(0.0, min(1.0, Saturation / 100.0))
v = max(0.0, min(1.0, Value / 100.0))
if s == 0.0:
r = g = b = v
else:
h = h * 6.0
i = int(h)
f = h - i
p = v * (1.0 - s)
q = v * (1.0 - s * f)
t = v * (1.0 - s * (1.0 - f))
if i == 0:
r, g, b = v, t, p
elif i == 1:
r, g, b = q, v, p
elif i == 2:
r, g, b = p, v, t
elif i == 3:
r, g, b = p, q, v
elif i == 4:
r, g, b = t, p, v
else:
r, g, b = v, p, q
return pack_rgb(r, g, b)
# =============================================================================
# HSL
# =============================================================================
class mdPrimitiveColorHSL:
@classmethod
def INPUT_TYPES(cls) -> dict:
return {
"required": {
"Hue": ("FLOAT", {"default": 0, "min": 0, "max": 360, "step": 0.1}),
"Saturation": ("FLOAT", {"default": 0, "min": 0, "max": 100, "step": 0.1}),
"Lightness": ("FLOAT", {"default": 0, "min": 0, "max": 100, "step": 0.1}),
},
}
RETURN_TYPES = ("INT", "STRING")
RETURN_NAMES = ("int", "HEX")
FUNCTION = "exec"
CATEGORY = module_cat
def exec(self, Hue, Saturation, Lightness):
h = Hue / 360.0
s = max(0.0, min(1.0, Saturation / 100.0))
l = max(0.0, min(1.0, Lightness / 100.0))
if s == 0.0:
r = g = b = l
else:
def hue2rgb(p, q, t):
if t < 0.0: t += 1.0
if t > 1.0: t -= 1.0
if t < 1.0 / 6.0: return p + (q - p) * 6.0 * t
if t < 1.0 / 2.0: return q
if t < 2.0 / 3.0: return p + (q - p) * (2.0 / 3.0 - t) * 6.0
return p
q = l * (1.0 + s) if l < 0.5 else l + s - l * s
p = 2.0 * l - q
r = hue2rgb(p, q, h + 1.0 / 3.0)
g = hue2rgb(p, q, h)
b = hue2rgb(p, q, h - 1.0 / 3.0)
return pack_rgb(r, g, b)
# =============================================================================
# CMYK
# =============================================================================
class mdPrimitiveColorCMYK:
@classmethod
def INPUT_TYPES(cls) -> dict:
return {
"required": {
"Cyan": ("FLOAT", {"default": 0, "min": 0, "max": 100, "step": 0.1}),
"Magenta": ("FLOAT", {"default": 0, "min": 0, "max": 100, "step": 0.1}),
"Yellow": ("FLOAT", {"default": 0, "min": 0, "max": 100, "step": 0.1}),
"Key": ("FLOAT", {"default": 0, "min": 0, "max": 100, "step": 0.1}),
},
}
RETURN_TYPES = ("INT", "STRING")
RETURN_NAMES = ("int", "HEX")
FUNCTION = "exec"
CATEGORY = module_cat
def exec(self, Cyan, Magenta, Yellow, Key):
c = max(0.0, min(1.0, Cyan / 100.0))
m = max(0.0, min(1.0, Magenta / 100.0))
y = max(0.0, min(1.0, Yellow / 100.0))
k = max(0.0, min(1.0, Key / 100.0))
r = (1.0 - c) * (1.0 - k)
g = (1.0 - m) * (1.0 - k)
b = (1.0 - y) * (1.0 - k)
return pack_rgb(r, g, b)
# =============================================================================
# CIE 1931 XYZ
# =============================================================================
class mdPrimitiveColorXYZ:
@classmethod
def INPUT_TYPES(cls) -> dict:
return {
"required": {
"X": ("FLOAT", {"default": 0.95047, "min": 0.0, "max": 1.0, "step": 0.001}),
"Y": ("FLOAT", {"default": 1.00000, "min": 0.0, "max": 1.0, "step": 0.001}),
"Z": ("FLOAT", {"default": 1.08883, "min": 0.0, "max": 1.0, "step": 0.001}),
},
}
RETURN_TYPES = ("INT", "STRING")
RETURN_NAMES = ("int", "HEX")
FUNCTION = "exec"
CATEGORY = module_cat
def exec(self, X, Y, Z):
# XYZ → linear sRGB (D65)
r_lin = 3.2404542 * X - 1.5371385 * Y - 0.4985314 * Z
g_lin = -0.9692660 * X + 1.8760108 * Y + 0.0415560 * Z
b_lin = 0.0556434 * X - 0.2040259 * Y + 1.0572252 * Z
def gamma(c):
if c <= 0.0031308:
return 12.92 * c
else:
return 1.055 * (c ** (1.0 / 2.4)) - 0.055
r = gamma(r_lin)
g = gamma(g_lin)
b = gamma(b_lin)
return pack_rgb(r, g, b)
# =============================================================================
# CIELAB
# =============================================================================
class mdPrimitiveColorLAB:
@classmethod
def INPUT_TYPES(cls) -> dict:
return {
"required": {
"L": ("FLOAT", {"default": 50, "min": 0, "max": 100, "step": 0.1}),
"A": ("FLOAT", {"default": 0, "min": -128, "max": 128, "step": 0.1}),
"B": ("FLOAT", {"default": 0, "min": -128, "max": 128, "step": 0.1}),
},
}
RETURN_TYPES = ("INT", "STRING")
RETURN_NAMES = ("int", "HEX")
FUNCTION = "exec"
CATEGORY = module_cat
def exec(self, L, A, B):
# D65 reference white
xn, yn, zn = 0.95047, 1.0, 1.08883
delta = 6.0 / 29.0
fy = (L + 16.0) / 116.0
fx = fy + (A / 500.0)
fz = fy - (B / 200.0)
def f_inv(t):
if t > delta:
return t ** 3
else:
return 3.0 * delta ** 2 * (t - 4.0 / 29.0)
X = xn * f_inv(fx)
Y = yn * f_inv(fy)
Z = zn * f_inv(fz)
r_lin = 3.2404542 * X - 1.5371385 * Y - 0.4985314 * Z
g_lin = -0.9692660 * X + 1.8760108 * Y + 0.0415560 * Z
b_lin = 0.0556434 * X - 0.2040259 * Y + 1.0572252 * Z
def gamma(c):
if c <= 0.0031308:
return 12.92 * c
else:
return 1.055 * (c ** (1.0 / 2.4)) - 0.055
r = gamma(r_lin)
g = gamma(g_lin)
b = gamma(b_lin)
return pack_rgb(r, g, b)
# =============================================================================
# Y′CbCr
# =============================================================================
class mdPrimitiveColorYCbCr:
@classmethod
def INPUT_TYPES(cls) -> dict:
return {
"required": {
"Luma": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.001}),
"BlueDiff": ("FLOAT", {"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.001}),
"RedDiff": ("FLOAT", {"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.001}),
},
}
RETURN_TYPES = ("INT", "STRING")
RETURN_NAMES = ("int", "HEX")
FUNCTION = "exec"
CATEGORY = module_cat
def exec(self, Luma, BlueDiff, RedDiff):
Y = Luma
Cb = BlueDiff
Cr = RedDiff
r = Y + 1.402 * Cr
g = Y - 0.344136 * Cb - 0.714136 * Cr
b = Y + 1.772 * Cb
return pack_rgb(r, g, b)
# =============================================================================
# Node registry
# =============================================================================
NODE_CLASS_MAPPINGS = {
"mdPrimitiveColorHSV": mdPrimitiveColorHSV,
"mdPrimitiveColorHSL": mdPrimitiveColorHSL,
"mdPrimitiveColorCMYK": mdPrimitiveColorCMYK,
"mdPrimitiveColorXYZ": mdPrimitiveColorXYZ,
"mdPrimitiveColorLAB": mdPrimitiveColorLAB,
"mdPrimitiveColorYCbCr": mdPrimitiveColorYCbCr,
"mdPrimitiveColorRGB": mdPrimitiveColorRGB,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"mdPrimitiveColorRGB": "Primitive Color RGB (MD)",
"mdPrimitiveColorHSV": "Primitive Color HSV (MD)",
"mdPrimitiveColorHSL": "Primitive Color HSL (MD)",
"mdPrimitiveColorCMYK": "Primitive Color CMYK (MD)",
"mdPrimitiveColorXYZ": "Primitive Color CIE XYZ (MD)",
"mdPrimitiveColorLAB": "Primitive Color CIELAB (MD)",
"mdPrimitiveColorYCbCr": "Primitive Color YCbCr (MD)",
}
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
module_cat = "md/layers"
from ..py.layers_blending import composite_layer_stack
#===================================================================================
# Node class – declaration only, execution delegated to the function below
#===================================================================================
class mdLayerStackNode:
"""
Composites a layer over a background using a blend mode, a mask, and a global transparency.
Handles RGBA images (4 channels) and respects the layer's own alpha channel.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"background": ("IMAGE",),
"layer": ("IMAGE",),
"blending_mode": (["Normal", "Screen", "Multiply", "Overlay", "Difference",
"Dissolve", "Clear", "Add", "Subtract", "Replace",
"Darken Only", "Lighten Only", "Color Dodge", "Color Burn",
"Hard Light", "Soft Light", "Pin Light", "Linear Dodge (Add)",
"Linear Burn (Multiply)", "Vivid Light", "Halo", "Negate",
"Merge RGB", "Merge Cyan", "Merge Magenta", "Merge Yellow",
"Merge Black", "Fill with FG Color", "Fill with BG Color",
"Average", "Composite", "Behind", "Template"],),
"transparency": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
},
"optional": {
"mask": ("MASK",),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "exec"
CATEGORY = module_cat
def exec(self, background, layer, blending_mode, transparency, mask=None):
return composite_layer_stack(background, layer, blending_mode, transparency, mask)
#===================================================================================
NODE_CLASS_MAPPINGS = {
"mdLayerStackNode": mdLayerStackNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"mdLayerStackNode": "Layer Stack Node (MD)",
}
+137
View File
@@ -0,0 +1,137 @@
import torch
from typing import Tuple, Optional
from .libs.color import rgb_to_lch_torch, lch_to_rgb_torch
from .libs.process import compute_chunk_size
def exec_operator_color_auto_color_enhance(
image: torch.Tensor,
mask: Optional[torch.Tensor] = None
) -> Tuple[torch.Tensor]:
"""
Apply GIMP‑style “Color Enhance” (chroma stretching) to an RGB or RGBA image.
The enhancement linearly stretches the chroma (C) channel of each image in the
batch so that its minimum mapped chroma becomes 0 and its maximum becomes 100.
When a mask is supplied, only the masked region is used to determine the per‑
image chroma range, and the result is blended with the original according to
the per‑pixel mask strength.
Processing is performed in chunks to manage GPU memory; the chunk size is
determined by ``compute_chunk_size``.
Args:
image: Image tensor of shape ``[B, H, W, C]`` with ``C = 3`` (RGB) or
``C = 4`` (RGBA). Values are expected in [0, 1].
mask: Optional mask tensor of shape ``[B, H, W]`` or ``[B, H, W, 1]``,
values in [0, 1]. Pixels with value > 0.5 are considered for
chroma range calculation; the mask also controls blending strength
when non‑zero.
Returns:
A single‑element tuple ``(enhanced_image,)`` with the same shape and
dtype as the input, clamped to [0, 1]. Alpha channels (if present) are
passed through unchanged.
"""
# --- validation ------------------------------------------------------------
if image.dim() != 4:
raise ValueError(f"Expected image tensor of shape [B, H, W, C], got {image.shape}")
B, H, W, C = image.shape
if C not in (3, 4):
raise ValueError(f"Image must have 3 (RGB) or 4 (RGBA) channels, got {C}")
if mask is not None:
if mask.dim() not in (3, 4):
raise ValueError(f"Mask must be 3‑ or 4‑dimensional, got {mask.dim()}D")
if mask.shape[0] != B:
raise ValueError(f"Batch size mismatch: image {B} vs mask {mask.shape[0]}")
# Squeeze trailing dimension if needed → [B, H, W, 1]
if mask.dim() == 3:
mask = mask.unsqueeze(-1)
elif mask.shape[-1] != 1:
mask = mask[..., :1] # keep first channel only
mask = mask.to(device=image.device, dtype=image.dtype)
# --- RGBA support: split alpha, process RGB only -------------------------
has_alpha = (C == 4)
if has_alpha:
rgb = image[..., :3]
alpha = image[..., 3:] # [B, H, W, 1]
else:
rgb = image
# --- chunked processing ---------------------------------------------------
chunk_size = compute_chunk_size(image, mask)
enhanced_chunks = []
for start in range(0, B, chunk_size):
end = min(start + chunk_size, B)
rgb_chunk = rgb[start:end] # [chunk, H, W, 3]
m_chunk = mask[start:end] if mask is not None else None
# ---------- per‑image chroma range with optional mask -----------------
lch = rgb_to_lch_torch(rgb_chunk) # [chunk, H, W, 3]
if m_chunk is not None:
# Boolean mask: True where mask > 0.5
mask_bool = m_chunk > 0.5 # [chunk, H, W, 1]
# Prepare C channel for masked min/max (vectorised)
C_chan = lch[..., 1] # [chunk, H, W]
# fill non‑masked with +inf for min, -inf for max
C_min = C_chan.masked_fill(~mask_bool.squeeze(-1), float('inf'))
C_max = C_chan.masked_fill(~mask_bool.squeeze(-1), float('-inf'))
minC = C_min.amin(dim=(1, 2)) # [chunk]
maxC = C_max.amax(dim=(1, 2))
# Handle images with no valid mask pixels → set range to 0
any_mask = mask_bool.any(dim=(1, 2, 3)).squeeze(-1) # [chunk]
minC = torch.where(any_mask, minC, torch.zeros_like(minC))
maxC = torch.where(any_mask, maxC, torch.zeros_like(maxC))
else:
C_chan = lch[..., 1]
minC = C_chan.amin(dim=(1, 2))
maxC = C_chan.amax(dim=(1, 2))
# ---------- early exit if no chroma variation in the chunk ------------
delta = maxC - minC # [chunk]
if not delta.any(): # all deltas ≈ 0
# No change – just forward the original chunk
enhanced_chunks.append(rgb_chunk)
continue
# ---------- stretch chroma --------------------------------------------
valid = delta > 1e-12 # [chunk]
scale = torch.zeros_like(delta)
offset = torch.zeros_like(delta)
scale[valid] = 100.0 / delta[valid]
offset[valid] = -minC[valid] * scale[valid]
# Broadcast to [chunk, 1, 1]
scale = scale.view(-1, 1, 1)
offset = offset.view(-1, 1, 1)
valid = valid.view(-1, 1, 1)
C_new = torch.where(valid, lch[..., 1] * scale + offset, lch[..., 1])
lch_new = torch.stack([lch[..., 0], C_new, lch[..., 2]], dim=-1)
# ---------- convert back to RGB ---------------------------------------
rgb_enh = lch_to_rgb_torch(lch_new)
# ---------- blend with mask if provided -------------------------------
if m_chunk is not None:
# mask acts as alpha blending factor (per pixel)
rgb_enh = m_chunk * rgb_enh + (1.0 - m_chunk) * rgb_chunk
enhanced_chunks.append(rgb_enh)
# --- reassemble batch and handle alpha ------------------------------------
enhanced_rgb = torch.cat(enhanced_chunks, dim=0) # [B, H, W, 3]
if has_alpha:
output = torch.cat([enhanced_rgb, alpha], dim=-1)
else:
output = enhanced_rgb
return (output.to(device=image.device, dtype=image.dtype),)
+113
View File
@@ -0,0 +1,113 @@
import torch
from typing import Tuple, Optional
from .libs.color import rgb_to_hsv, hsv_to_rgb
from .libs.process import compute_chunk_size
def exec_operator_color_auto_equalize(
image: torch.Tensor,
mask: Optional[torch.Tensor] = None
) -> Tuple[torch.Tensor]:
"""
Auto‑equalise the V (value) channel of an RGB or RGBA image batch.
The equalisation curve (LUT) is computed from the histogram of all pixels
in the batch. If a mask is provided, it acts as a per‑pixel strength factor:
- 1.0 → fully equalised
- 0.0 → original V channel preserved
- Values between 0 and 1 blend linearly between the two.
Alpha channels are left untouched.
Parameters
----------
image : torch.Tensor
(B, H, W, C) with C=3 (RGB) or C=4 (RGBA).
Dtype: torch.uint8 (0–255) or torch.float32 (0–1).
mask : torch.Tensor, optional
(B, H, W) tensor with values in [0,1] representing application strength.
If omitted, all pixels are fully equalised (mask = 1).
Returns
-------
tuple of torch.Tensor
A 1‑tuple containing the equalised image tensor, same shape and dtype
as the input.
"""
# ---------- validation --------------------------------------------------
if image.dim() != 4:
raise ValueError(f"Expected 4D tensor (B,H,W,C), got {image.dim()}D")
B, H, W, C = image.shape
if C not in (3, 4):
raise ValueError(f"Only 3 (RGB) or 4 (RGBA) channels supported, got {C}")
if mask is not None:
if mask.dim() != 3:
raise ValueError(f"Mask must be 3D (B,H,W), got {mask.dim()}D")
if mask.shape != (B, H, W):
raise ValueError(
f"Mask shape {mask.shape} incompatible with image shape {image.shape}"
)
# ---------- separate alpha if present -----------------------------------
if C == 4:
alpha = image[..., 3:4]
img_rgb = image[..., :3]
else:
alpha = None
img_rgb = image
# ---------- normalise to float [0,1] ------------------------------------
if img_rgb.dtype == torch.uint8:
img_float = img_rgb.float() / 255.0
else:
img_float = img_rgb.float()
# ---------- RGB → HSV ---------------------------------------------------
h, s, v = rgb_to_hsv(img_float) # all shapes (B,H,W)
# Early exit on truly empty input
if v.numel() == 0:
return (image,)
# ---------- chunked histogram over ALL pixels (mask ignored) ------------
bins = 256
hist = torch.zeros(bins, device=v.device, dtype=torch.float32)
chunk_size = compute_chunk_size(image, mask)
v_flat = v.reshape(-1)
n_vals = v_flat.numel()
for start in range(0, n_vals, chunk_size):
end = min(start + chunk_size, n_vals)
hist += torch.histc(v_flat[start:end], bins=bins, min=0.0, max=1.0)
# Build normalised CDF (LUT)
cdf = torch.cumsum(hist, dim=0)
lut = cdf / cdf[-1].clamp_min(1e-8)
# Apply LUT → equalised V
idx = (v * (bins - 1)).long().clamp(0, bins - 1)
v_eq = lut[idx] # (B,H,W)
# ---------- blend with original V using mask strength -------------------
if mask is not None:
# mask values in [0,1] directly control blend
v_final = v_eq * mask + v * (1.0 - mask)
else:
v_final = v_eq
# ---------- HSV → RGB ---------------------------------------------------
out_rgb = hsv_to_rgb(h, s, v_final).clamp(0, 1)
# ---------- restore original dtype and alpha ----------------------------
if img_rgb.dtype == torch.uint8:
out_rgb = (out_rgb * 255).round().to(torch.uint8)
if alpha is not None:
out = torch.cat([out_rgb, alpha], dim=-1)
else:
out = out_rgb
return (out,)
+280
View File
@@ -0,0 +1,280 @@
import torch
from typing import Optional, Tuple
from .libs.color import hsv_to_rgb, rgb_to_hsv
from .libs.process import compute_chunk_size
EPS = 1e-8
def _normalize_mask(mask: torch.Tensor, device, B, H, W):
"""
Returns:
mask_f: float32 per-pixel strength mask in [0,1]
mask_bool: boolean mask for statistics
"""
if mask is None:
return None, None
mask = mask.to(device)
if mask.ndim == 2:
mask = mask.unsqueeze(0)
if mask.ndim == 3:
mask = mask.unsqueeze(-1)
if mask.shape[-1] != 1:
raise ValueError("Mask must have a single channel")
if mask.shape[0] == 1 and B > 1:
mask = mask.expand(B, -1, -1, -1)
elif mask.shape[0] != B:
raise ValueError("Mask batch size mismatch")
mask_f = mask.clamp(0.0, 1.0).float()
mask_bool = mask_f > 0.0
return mask_f, mask_bool
def _check_input(image: torch.Tensor):
if image is None:
raise ValueError("image cannot be None")
image = image.float()
if image.ndim == 3:
image = image.unsqueeze(0)
if image.shape[-1] not in (3, 4):
raise ValueError(f"Expected RGB/RGBA, got {image.shape}")
return image
def compute_vram_chunk(image: torch.Tensor, mask: Optional[torch.Tensor]):
"""
Wrapper around user-provided compute_chunk_size with safety fallback.
"""
try:
cs = compute_chunk_size(image, mask)
return max(1, int(cs))
except Exception:
return max(1, image.shape[0])
def _early_exit_check(image, mask_bool):
"""
Fast-path detection:
- empty batch
- no usable mask
"""
if image.shape[0] == 0:
return True
if mask_bool is not None and not mask_bool.any():
return True
return False
def _stretch_chunk_rgb(
rgb: torch.Tensor, # (B,H,W,3)
mask_f: Optional[torch.Tensor], # (B,H,W,1) float or None
mask_bool: Optional[torch.Tensor],
keep_colors: bool,
non_linear: bool,
):
"""
Apply auto-stretch contrast in RGB space (GIMP-equivalent).
All operations are batch-vectorized and GPU-safe.
"""
device = rgb.device
B, H, W, C = rgb.shape
work = rgb
# ── linear light conversion (optional) ─────────────────────────
if non_linear:
work = work.clamp(0.0, 1.0).pow(1.0 / 2.2)
# ── reshape for reduction (B, N, C) ────────────────────────────
flat = work.view(B, H * W, C)
if mask_bool is not None:
mask_flat = mask_bool.view(B, H * W)
# avoid invalid pixels affecting reduction
masked = torch.where(
mask_flat.unsqueeze(-1),
flat,
torch.tensor(float("inf"), device=device)
)
masked_neg = torch.where(
mask_flat.unsqueeze(-1),
flat,
torch.tensor(float("-inf"), device=device)
)
mins = masked.amin(dim=1)
maxs = masked_neg.amax(dim=1)
# fallback safety (empty mask per channel)
mins = torch.where(torch.isinf(mins), torch.zeros_like(mins), mins)
maxs = torch.where(torch.isinf(maxs), torch.ones_like(maxs), maxs)
else:
mins = flat.amin(dim=1)
maxs = flat.amax(dim=1)
# ── shared channel behavior (GIMP "preserve colors") ───────────
if keep_colors:
lo = mins.amin(dim=1, keepdim=True) # (B,1)
hi = maxs.amax(dim=1, keepdim=True) # (B,1)
mins = lo.expand_as(mins)
maxs = hi.expand_as(maxs)
# ── avoid division by zero ─────────────────────────────────────
denom = (maxs - mins).clamp(min=EPS)
# ── reshape for broadcast ───────────────────────────────────────
mins = mins.view(B, 1, 1, 3)
maxs = maxs.view(B, 1, 1, 3)
denom = denom.view(B, 1, 1, 3)
# ── apply stretch (GIMP-equivalent formula) ─────────────────────
out = (work - mins) / denom
out = out.clamp(0.0, 1.0)
# ── revert gamma if needed ──────────────────────────────────────
if non_linear:
out = out.pow(2.2)
return out
def exec_operator_color_auto_stretch_contrast(
image: torch.Tensor,
stretch_type: str = "RGB",
keep_colors: bool = True,
non_linear: bool = False,
mask: torch.Tensor = None,
low_percentile: float = 0.0,
high_percentile: float = 1.0,
) -> Tuple[torch.Tensor]:
"""
Applies auto-stretch contrast to an image batch.
The operation expands the dynamic range of the image so that its
minimum and maximum values span the full [0,1] interval.
When a mask is provided, it:
- defines the region used for statistical computation
- and controls per-pixel strength of the effect
Alpha channels are preserved unchanged.
"""
# ── input validation ────────────────────────────────────────────
image = _check_input(image)
device = image.device
if stretch_type.upper() not in ("RGB", "HSV"):
raise ValueError("stretch_type must be 'RGB' or 'HSV'")
# ── split alpha ────────────────────────────────────────────────
has_alpha = image.shape[-1] == 4
rgb = image[..., :3].clamp(0.0, 1.0)
alpha = image[..., 3:] if has_alpha else None
B, H, W, _ = rgb.shape
# ── normalize mask ─────────────────────────────────────────────
mask_f, mask_bool = _normalize_mask(mask, device, B, H, W)
# ── early exit ────────────────────────────────────────────────
if _early_exit_check(rgb, mask_bool):
return (image.clone(),)
# ── VRAM chunking ─────────────────────────────────────────────
chunk_size = compute_vram_chunk(rgb, mask_f)
rgb_out = torch.empty_like(rgb)
# ── process chunks ─────────────────────────────────────────────
for start in range(0, B, chunk_size):
end = min(start + chunk_size, B)
sub_rgb = rgb[start:end]
sub_mask_f = mask_f[start:end] if mask_f is not None else None
sub_mask_b = mask_bool[start:end] if mask_bool is not None else None
if stretch_type.upper() == "RGB":
stretched = _stretch_chunk_rgb(
sub_rgb,
sub_mask_f,
sub_mask_b,
keep_colors,
non_linear,
)
else:
# ── HSV path (value channel stretch) ───────────────────
hsv = rgb_to_hsv(sub_rgb)
# ── normalize HSV format ───────────────────────────────────────
if isinstance(hsv, tuple):
if len(hsv) == 3:
h, s, v = hsv
else:
hsv = hsv[0]
h, s, v = hsv[..., 0], hsv[..., 1], hsv[..., 2]
else:
h, s, v = hsv[..., 0], hsv[..., 1], hsv[..., 2]
# ── compute min/max on V channel ───────────────────────────────
flat_v = v.view(v.shape[0], -1)
if sub_mask_b is not None:
mask_flat = sub_mask_b.view(v.shape[0], -1)
v_masked = torch.where(
mask_flat.unsqueeze(-1),
v.view(v.shape[0], -1, 1),
torch.tensor(float("inf"), device=device)
)
v_min = v_masked.amin(dim=1)
v_max = torch.where(
mask_flat.unsqueeze(-1),
v.view(v.shape[0], -1, 1),
torch.tensor(float("-inf"), device=device)
).amax(dim=1)
v_min = torch.where(torch.isinf(v_min), torch.zeros_like(v_min), v_min)
v_max = torch.where(torch.isinf(v_max), torch.ones_like(v_max), v_max)
else:
v_min = flat_v.amin(dim=1)
v_max = flat_v.amax(dim=1)
denom = (v_max - v_min).clamp(min=EPS).view(-1, 1, 1)
v = (v - v_min.view(-1, 1, 1)) / denom
v = v.clamp(0.0, 1.0)
# ── rebuild HSV → RGB correctly ────────────────────────────────
stretched = hsv_to_rgb(h, s, v)
# ── PER-PIXEL MASK STRENGTH BLENDING (REQUIRED) ─────────────
if mask_f is not None:
mf = sub_mask_f
stretched = rgb[start:end] + (stretched - rgb[start:end]) * mf
rgb_out[start:end] = stretched
# ── reattach alpha ─────────────────────────────────────────────
if has_alpha:
rgb_out = torch.cat((rgb_out, alpha), dim=-1)
return (rgb_out,)
+94
View File
@@ -0,0 +1,94 @@
import torch
from typing import Tuple
def exec_operator_color_auto_white_balance(
image: torch.Tensor,
mask: torch.Tensor = None
) -> Tuple[torch.Tensor]:
"""
Auto White Balance (GIMP-style percentile stretch per channel).
Args:
image: [B, H, W, C] float tensor in [0,1]
mask: [B, 1, H, W] optional weighting mask (0–1)
Returns:
Tuple containing processed image [B, H, W, C]
"""
if image.dtype != torch.float32:
image = image.float()
B, H, W, C = image.shape
eps = 1e-6
# -----------------------------
# Split RGB / Alpha
# -----------------------------
rgb = image[..., :min(3, C)] # [B,H,W,3]
has_alpha = C > 3
alpha = image[..., 3:] if has_alpha else None
# Convert to NCHW
rgb = rgb.permute(0, 3, 1, 2).contiguous() # [B,3,H,W]
B, Cc, H, W = rgb.shape
# -----------------------------
# Apply mask if provided
# -----------------------------
if mask is not None:
if mask.dtype != torch.float32:
mask = mask.float()
if mask.ndim == 4:
mask = mask[:, 0:1, :, :]
mask = torch.nn.functional.interpolate(mask, size=(H, W), mode="bilinear", align_corners=False)
mask = mask.clamp(0.0, 1.0)
else:
mask = torch.ones((B, 1, H, W), device=rgb.device, dtype=rgb.dtype)
# -----------------------------
# Compute weighted percentiles
# -----------------------------
flat_rgb = rgb.reshape(B, Cc, -1) # [B,3,N]
flat_mask = mask.reshape(B, 1, -1) # [B,1,N]
# Sort values per channel
sorted_vals, indices = torch.sort(flat_rgb, dim=2)
sorted_weights = torch.gather(flat_mask.expand_as(flat_rgb), 2, indices)
cum_weights = torch.cumsum(sorted_weights, dim=2)
total_weights = cum_weights[:, :, -1:] + eps
cum_norm = cum_weights / total_weights
lower_p = 0.0005 # 0.05%
upper_p = 0.9995 # 99.95%
def percentile_value(p):
# first index where cum_norm >= p
idx = torch.searchsorted(cum_norm, torch.full((B, Cc, 1), p, device=rgb.device))
idx = idx.clamp(0, sorted_vals.shape[2] - 1)
return torch.gather(sorted_vals, 2, idx)
lower = percentile_value(lower_p)
upper = percentile_value(upper_p)
# -----------------------------
# Stretch
# -----------------------------
denom = (upper - lower).clamp(min=eps)
stretched = (flat_rgb - lower) / denom
stretched = stretched.clamp(0.0, 1.0)
rgb_out = stretched.reshape(B, Cc, H, W).permute(0, 2, 3, 1).contiguous()
# -----------------------------
# Reattach alpha
# -----------------------------
if has_alpha:
rgb_out = torch.cat([rgb_out, alpha], dim=-1)
return (rgb_out,)
+129
View File
@@ -0,0 +1,129 @@
import torch
from typing import Tuple, Optional
@torch.no_grad() # 1) no gradient tracking → saves VRAM & time
def exec_operation_color_balance(
image: torch.Tensor, # shape: (B, H, W, C) with C = 3 or 4
mask: Optional[torch.Tensor], # can be (H,W), (B,H,W), (B,H,W,1), ...
shadow_adj: Tuple[float, float, float],
midtones_adj: Tuple[float, float, float],
highlights_adj: Tuple[float, float, float],
preserve_luminosity: bool
) -> torch.Tensor:
"""
Apply GIMP‑style color balance.
Each adjustment tuple = (Cyan‑Red, Magenta‑Green, Yellow‑Blue) in [-100, 100].
Improvements over original:
- Robust batch‑mask handling (2D mask → broadcast to whole batch)
- Full RGB / RGBA support (alpha channel is never color‑shifted)
- Input validation with clear error messages
- `torch.no_grad()` + single memory allocation → VRAM optimised
- Works on any device (CPU / CUDA / MPS)
"""
# ----------------------------------------------------------------------
# 1. Input validation & preparation
# ----------------------------------------------------------------------
if image.dim() != 4:
raise ValueError(f"image must be 4D (B,H,W,C), got {image.dim()}D")
B, H, W, C = image.shape
if C not in (3, 4):
raise ValueError(f"only 3 (RGB) or 4 (RGBA) channels allowed, got {C}")
for adj in (shadow_adj, midtones_adj, highlights_adj):
if len(adj) != 3:
raise ValueError("each adjustment must be a sequence of 3 floats")
device = image.device
# Work on a float copy (same device)
img = image.to(dtype=torch.float32, copy=True) # single allocation
rgb = img[..., :3] # view – no copy
# ----------------------------------------------------------------------
# 2. Prepare mask → broadcastable (B, H, W, 1)
# ----------------------------------------------------------------------
mask_tensor: Optional[torch.Tensor] = None
if mask is not None:
m = mask.to(device=device, dtype=torch.float32)
# unify shapes
if m.dim() == 2: # (H, W) → apply to all images
m = m[None, ..., None] # (1, H, W, 1)
m = m.expand(B, -1, -1, -1) # (B, H, W, 1)
elif m.dim() == 3:
if m.shape[-1] == 1: # (H, W, 1) or (B, H, W, 1)?
if m.shape[0] == B:
m = m # (B, H, W, 1) – already fine
else:
m = m[None] # (1, H, W, 1)
m = m.expand(B, -1, -1, -1)
else: # (B, H, W)
m = m.unsqueeze(-1) # (B, H, W, 1)
elif m.dim() == 4:
if m.shape[0] == 1 and B > 1:
m = m.expand(B, -1, -1, -1) # broadcast batch dimension
else:
raise ValueError(f"mask shape {mask.shape} not supported")
# safety check
if m.shape[:3] != (B, H, W):
raise ValueError(
f"mask spatial dimensions must match image. "
f"Expected (B,H,W)=({B},{H},{W}), got {m.shape[:3]}"
)
mask_tensor = m
# ----------------------------------------------------------------------
# 3. Luma & transfer‑function weights (Rec. 709)
# ----------------------------------------------------------------------
luma_w = torch.tensor([0.299, 0.587, 0.114], device=device, dtype=torch.float32)
luma = (rgb * luma_w).sum(dim=-1, keepdim=True) # (B, H, W, 1)
def smoothstep(edge0, edge1, x):
t = torch.clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0)
return t * t * (3.0 - 2.0 * t)
shadow_w = smoothstep(0.5, 0.0, luma) # 1 at 0, 0 at 0.5
midtones_w = 4.0 * luma * (1.0 - luma) # peak 1 at 0.5
highlights_w = smoothstep(0.5, 1.0, luma) # 0 at 0.5, 1 at 1.0
# ----------------------------------------------------------------------
# 4. Normalised adjustments → (1,1,1,3)
# ----------------------------------------------------------------------
def to_tensor(adj):
return torch.tensor(adj, device=device, dtype=torch.float32).view(1,1,1,3) / 100.0
delta = (
shadow_w * to_tensor(shadow_adj) +
midtones_w * to_tensor(midtones_adj) +
highlights_w * to_tensor(highlights_adj)
) # (B, H, W, 3)
# ----------------------------------------------------------------------
# 5. Apply delta (with or without mask)
# ----------------------------------------------------------------------
if mask_tensor is not None:
blended = rgb + delta * mask_tensor # only RGB
else:
blended = rgb + delta
# Build output (keep alpha untouched)
if C == 4:
alpha = img[..., 3:4]
out = torch.cat([blended, alpha], dim=-1)
else:
out = blended
# ----------------------------------------------------------------------
# 6. Optional luminance preservation (RGB only)
# ----------------------------------------------------------------------
if preserve_luminosity:
new_luma = (out[..., :3] * luma_w).sum(dim=-1, keepdim=True)
eps = 1e-8
scale = luma / (new_luma + eps)
out[..., :3] = out[..., :3] * scale
# ----------------------------------------------------------------------
# 7. Final clamp (entire image, including alpha, exactly like original)
# ----------------------------------------------------------------------
out.clamp_(0.0, 1.0)
return out
+131
View File
@@ -0,0 +1,131 @@
import torch
import math
from typing import Tuple, Optional
from .libs.process import compute_chunk_size
def exec_operator_color_brightness_contrast(
image: torch.Tensor,
brightness: float,
contrast: float,
mask: Optional[torch.Tensor] = None
) -> Tuple[torch.Tensor]:
"""
Apply GIMP‑style brightness and contrast adjustment to a batch of images.
This function processes an entire batch in GPU‑friendly chunks to manage
VRAM consumption. Chunk size is determined by :func:`compute_chunk_size`.
**Input**:
image (torch.Tensor): shape ``(B, H, W, C)``, values in [0, 1].
Supported channel counts: ``C=3`` (RGB) or ``C=4`` (RGBA).
brightness (float): slider value in [-127, 127] (GIMP UI range).
contrast (float): slider value in [-127, 127].
mask (Optional[torch.Tensor]):
Optional mask of shape ``(B, H, W)`` or ``(B, H, W, 1)``,
values in [0, 1]. When provided, the adjustment is blended
per‑pixel using ``mask``.
**Output**:
Tuple containing a single tensor of shape ``(B, H, W, C)`` where ``C``
matches the input’s channel count. Alpha channel (if present) is
preserved unchanged.
**Processing logic**:
1. Convert slider values to GIMP internal ranges:
``b = (brightness/127) / 2``, ``slant = tan((contrast/127 + 1) * pi/4)``.
2. Apply brightness (GIMP formula) and contrast to the RGB channels.
3. Clamp results to [0, 1].
4. If a mask is given, blend the adjusted RGB with the original RGB.
5. Re‑attach the alpha channel if the input was RGBA.
**GPU memory management**:
The batch is split into chunks whose size is calculated by
``compute_chunk_size(image, mask)``. Each chunk is processed
independently, and the final result is concatenated along the
batch dimension. No spatial tiling is performed.
Returns:
``(result,)`` where *result* is the adjusted batch tensor.
"""
# ----------------------- Early exit -----------------------
if brightness == 0 and contrast == 0:
return (image,)
# ----------------------- Input validation -----------------
if not isinstance(image, torch.Tensor):
raise TypeError("image must be a torch.Tensor")
if image.dim() != 4:
raise ValueError(f"image must be 4D (B,H,W,C), got {image.dim()}D")
if image.shape[-1] not in (3, 4):
raise ValueError(f"image last dim must be 3 or 4, got {image.shape[-1]}")
if not image.is_floating_point():
raise TypeError("image tensor must be floating point")
if mask is not None:
if not isinstance(mask, torch.Tensor):
raise TypeError("mask must be a torch.Tensor")
if mask.dim() not in (3, 4):
raise ValueError(f"mask must be 3D (B,H,W) or 4D (B,H,W,1), got {mask.dim()}D")
if mask.shape[0] != image.shape[0]:
raise ValueError("mask batch size must equal image batch size")
if mask.shape[1:3] != image.shape[1:3]:
raise ValueError("mask spatial size must match image")
if mask.dim() == 4 and mask.shape[-1] != 1:
raise ValueError("4D mask must have 1 channel")
if mask.dtype != image.dtype:
mask = mask.to(dtype=image.dtype) # auto‑cast to avoid errors
# ----------------------- Chunked processing -----------------
chunk_size = compute_chunk_size(image, mask)
B = image.shape[0]
# If chunk_size is invalid or larger than batch, process all at once.
if chunk_size is None or chunk_size <= 0 or chunk_size >= B:
chunk_size = B
# Precompute constants shared across all chunks
brightness_internal = brightness / 127.0
contrast_internal = contrast / 127.0
b = brightness_internal / 2.0 # [-0.5, 0.5]
slant = math.tan((contrast_internal + 1) * math.pi / 4)
result_chunks = []
for start in range(0, B, chunk_size):
end = min(start + chunk_size, B)
img_chunk = image[start:end] # (cs, H, W, C)
m_chunk = mask[start:end] if mask is not None else None
# ---- RGB channels ----
rgb = img_chunk[..., :3] # (cs, H, W, 3)
# Brightness (GIMP)
if b < 0:
rgb = rgb * (1.0 + b)
else:
rgb = rgb + (1.0 - rgb) * b
# Contrast
rgb = (rgb - 0.5) * slant + 0.5
rgb = torch.clamp(rgb, 0.0, 1.0)
# Mask blending
if m_chunk is not None:
if m_chunk.dim() == 3:
m_chunk = m_chunk.unsqueeze(-1) # (cs, H, W, 1)
original = img_chunk[..., :3]
rgb = original * (1.0 - m_chunk) + rgb * m_chunk
# Alpha preservation
if img_chunk.shape[-1] == 4:
result_chunk = torch.cat([rgb, img_chunk[..., 3:4]], dim=-1)
else:
result_chunk = rgb
result_chunks.append(result_chunk)
# Concatenate all chunks back along batch dimension
result = torch.cat(result_chunks, dim=0)
return (result,)
+145
View File
@@ -0,0 +1,145 @@
import torch
from typing import Tuple, Optional
from .libs.process import compute_chunk_size
# ===================================================================================
# GEGL-perceptual Colorize operator (faithful approximation)
# ===================================================================================
def exec_operator_color_colorize(
image: torch.Tensor,
Hue: float,
Saturation: float,
Lightness: float,
Color: int,
mask: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor]:
"""
Apply a GEGL‑perceptual colourisation to a batch of images.
The algorithm works in the following steps:
1. Compute BT.709 perceptual luminance of the original image.
2. Decode the target colour and build a chroma vector.
3. Scale the chroma vector by **Hue × Saturation** and add it
to the luminance, effectively tinting the image.
4. Apply a luminance bias based on the **Lightness** parameter.
5. If a **mask** is provided, blend the result with the original
using per‑pixel mask values as strength.
The function supports RGB and RGBA input: alpha is preserved
unchanged and only the RGB channels are colourised. Batch
processing is handled internally with GPU‑friendly chunking.
Args:
image: [B, H, W, C] float tensor (C=3 or 4), values 0‑1.
Hue: float 0‑1, controls tint strength (together with Saturation).
Saturation: float 0‑1, scales the chroma direction.
Lightness: float 0‑1, brightness bias (0.5 = no change).
Color: 24‑bit integer (0xRRGGBB) of the target tint.
mask: Optional mask [H, W] or [B, H, W], values 0‑1.
Returns:
A 1‑tuple containing the colourised image tensor [B, H, W, C].
"""
# --- input validation ----------------------------------------------------
if not isinstance(image, torch.Tensor):
raise TypeError("image must be a torch.Tensor")
if image.dim() != 4:
raise ValueError(f"image must be 4D [B,H,W,C], got shape {image.shape}")
if image.shape[-1] not in (3, 4):
raise ValueError(f"image must have 3 or 4 channels, got {image.shape[-1]}")
if mask is not None:
if not isinstance(mask, torch.Tensor):
raise TypeError("mask must be a torch.Tensor")
if mask.dim() not in (2, 3):
raise ValueError(f"mask must be 2D [H,W] or 3D [B,H,W], got shape {mask.shape}")
if mask.dim() == 3 and mask.shape[0] != image.shape[0]:
raise ValueError(
f"batch size mismatch: image {image.shape[0]}, mask {mask.shape[0]}"
)
# device consistency check
if mask.device != image.device:
raise ValueError("image and mask must be on the same device")
# --- early exit conditions -----------------------------------------------
B = image.shape[0]
if B == 0:
return (image,)
if mask is not None and not mask.isnan().any() and mask.max() == 0.0:
# mask is completely black → original image is unchanged
return (image,)
# --- prepare alpha handling ----------------------------------------------
has_alpha = image.shape[-1] == 4
if has_alpha:
rgb = image[..., :3]
alpha = image[..., 3:4]
else:
rgb = image
alpha = None
# --- determine chunk size for GPU processing -----------------------------
chunk_size = compute_chunk_size(image, mask)
results = []
# --- chunked batch loop --------------------------------------------------
for start in range(0, B, chunk_size):
end = min(start + chunk_size, B)
chunk_rgb = rgb[start:end] # [C, H, W, 3]
chunk_mask = None
if mask is not None:
if mask.dim() == 3: # batched mask
chunk_mask = mask[start:end]
else: # shared 2D mask
chunk_mask = mask
# --- colourisation pipeline (identical to original) -------------------
# 1. Ensure linear RGB (clamp)
chunk_rgb = chunk_rgb.clamp(0, 1)
r, g, b = chunk_rgb[..., 0], chunk_rgb[..., 1], chunk_rgb[..., 2]
# 2. BT.709 perceptual luminance
Y = 0.222488 * r + 0.716903 * g + 0.060609 * b
# 3. Target colour
cr = ((Color >> 16) & 255) / 255.0
cg = ((Color >> 8) & 255) / 255.0
cb = (Color & 255) / 255.0
Yc = 0.222488 * cr + 0.716903 * cg + 0.060609 * cb
# 4. Chroma direction and tint scaling
dr, dg, db = cr - Yc, cg - Yc, cb - Yc
sat = Saturation * Hue
rr = Y + dr * sat
gg = Y + dg * sat
bb = Y + db * sat
out_rgb = torch.stack([rr, gg, bb], dim=-1)
# 5. Lightness bias
light_bias = Lightness - 0.5
out_rgb = out_rgb + light_bias
out_rgb = out_rgb.clamp(0, 1)
# 6. Mask blending (per‑pixel strength)
if chunk_mask is not None:
# ensure mask has a trailing channel dimension for broadcasting
if chunk_mask.dim() == 2:
chunk_mask = chunk_mask.unsqueeze(0).unsqueeze(-1) # [1, H, W, 1]
elif chunk_mask.dim() == 3:
chunk_mask = chunk_mask.unsqueeze(-1) # [B, H, W, 1]
out_rgb = chunk_rgb * (1 - chunk_mask) + out_rgb * chunk_mask
# 7. Reattach alpha if present
if has_alpha:
chunk_out = torch.cat([out_rgb, alpha[start:end]], dim=-1)
else:
chunk_out = out_rgb
results.append(chunk_out)
# concatenate all chunks back to full batch
final = torch.cat(results, dim=0)
return (final,)
+177
View File
@@ -0,0 +1,177 @@
import torch
from typing import Tuple, Optional
from .libs.process import compute_chunk_size
_LUMA = torch.tensor([0.2126, 0.7152, 0.0722])
def exec_operator_color_comp_channel_mixer(
image: torch.Tensor,
Red_in_red: float,
Green_in_red: float,
Blue_in_red: float,
Red_in_green: float,
Green_in_green: float,
Blue_in_green: float,
Red_in_blue: float,
Green_in_blue: float,
Blue_in_blue: float,
Preserve_luminosity: bool,
mask: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor]:
"""
Applies a channel‑mixing matrix to an RGB or RGBA image, optionally
modulated by a per‑pixel mask and with luminosity preservation.
Input:
image : torch.Tensor – batch of images, shape [B, H, W, C] with C=3 (RGB) or 4 (RGBA).
Red_in_red … Blue_in_blue : coefficients of the 3×3 mixing matrix.
Preserve_luminosity : if True, scales the mixed result so that the perceived
luminance matches the original before clamping.
mask : optional torch.Tensor – per‑pixel blend strength, broadcastable to
[B, H, W, 1]. Allowed shapes: [H, W], [B, H, W], [B, H, W, 1].
A pixel value of 1.0 gives full mixing; 0.0 leaves the original unchanged.
Output:
Tuple containing a single tensor of mixed images, same shape as input but
with RGB channels modified. If the input has an alpha channel, it is passed
through unchanged.
"""
# ---------- input validation ----------
if image.dim() != 4:
raise ValueError(
f"Input image must be a 4D tensor [B, H, W, C], got shape {image.shape}"
)
if image.shape[-1] not in (3, 4):
raise ValueError(
f"Input image must have 3 (RGB) or 4 (RGBA) channels, got {image.shape[-1]}"
)
if mask is not None:
if mask.dim() not in (2, 3, 4):
raise ValueError(
f"Mask must be 2D [H,W], 3D [B,H,W] or 4D [B,H,W,1], got shape {mask.shape}"
)
# ensure mask can be broadcast to [B, H, W, 1]
if mask.dim() == 2:
mask = mask.unsqueeze(0).unsqueeze(-1) # [1, H, W, 1]
elif mask.dim() == 3:
if mask.shape[0] != image.shape[0]:
mask = mask.unsqueeze(0).unsqueeze(-1) # treat as [1, H, W, 1]
else:
mask = mask.unsqueeze(-1) # [B, H, W, 1]
# mask already 4D – verify last dim == 1
if mask.shape[-1] != 1:
mask = mask.unsqueeze(-1)
if mask.shape[0] not in (1, image.shape[0]):
raise ValueError(
f"Mask batch dimension ({mask.shape[0]}) must be 1 or match image batch ({image.shape[0]})"
)
if mask.shape[1:3] != image.shape[1:3]:
raise ValueError(
f"Mask spatial size {mask.shape[1:3]} does not match image size {image.shape[1:3]}"
)
# ---------- early exit conditions ----------
identity = (
Red_in_red == 1.0
and Green_in_red == 0.0
and Blue_in_red == 0.0
and Red_in_green == 0.0
and Green_in_green == 1.0
and Blue_in_green == 0.0
and Red_in_blue == 0.0
and Green_in_blue == 0.0
and Blue_in_blue == 1.0
and not Preserve_luminosity
)
if mask is None and identity:
return (image,)
if mask is not None and torch.all(mask == 0.0):
return (image,)
if mask is not None and identity:
# identity transform + mask all zeros already caught;
# if mask not all zero we still need blending, so continue
pass
# ---------- chunked processing ----------
B = image.shape[0]
chunk_size = compute_chunk_size(image, mask)
results = []
for start in range(0, B, chunk_size):
end = min(start + chunk_size, B)
chunk_img = image[start:end]
# slice mask if it has a batch dim, otherwise keep the single mask
if mask is not None:
if mask.shape[0] == B:
chunk_mask = mask[start:end]
else:
chunk_mask = mask
# move to same device/dtype as image
chunk_mask = chunk_mask.to(device=chunk_img.device, dtype=chunk_img.dtype)
else:
chunk_mask = None
# -------- inner processing function (preserves original logic) --------
def _process_chunk(img: torch.Tensor, msk: Optional[torch.Tensor]) -> torch.Tensor:
has_alpha = img.shape[-1] == 4
if has_alpha:
alpha = img[..., 3:4] # preserve alpha
rgb = img[..., :3]
else:
rgb = img
r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
# matrix mixing (identical to original)
r_out = Red_in_red * r + Green_in_red * g + Blue_in_red * b
g_out = Red_in_green * r + Green_in_green * g + Blue_in_green * b
b_out = Red_in_blue * r + Green_in_blue * g + Blue_in_blue * b
# luminosity preservation
if Preserve_luminosity:
device = rgb.device
dtype = rgb.dtype
luma = _LUMA.to(device=device, dtype=dtype)
def channel_luma(a, b, c):
return a * luma[0] + b * luma[1] + c * luma[2]
orig_luma = luma[0] * r + luma[1] * g + luma[2] * b
mix_luma = (
channel_luma(Red_in_red, Green_in_red, Blue_in_red) * r
+ channel_luma(Red_in_green, Green_in_green, Blue_in_green) * g
+ channel_luma(Red_in_blue, Green_in_blue, Blue_in_blue) * b
)
eps = 1e-8
scale = orig_luma / (mix_luma + eps)
r_out = r_out * scale
g_out = g_out * scale
b_out = b_out * scale
# stack and clamp (only RGB part)
mixed_rgb = torch.stack([r_out, g_out, b_out], dim=-1)
# mask blending (applies only to RGB)
if msk is not None:
# broadcast mask from [B, H, W, 1] to [B, H, W, 3]
mixed_rgb = rgb * (1.0 - msk) + mixed_rgb * msk
mixed_rgb = torch.clamp(mixed_rgb, 0.0, 1.0)
if has_alpha:
return torch.cat([mixed_rgb, alpha], dim=-1)
return mixed_rgb
# -------- process the chunk --------
res_chunk = _process_chunk(chunk_img, chunk_mask)
results.append(res_chunk)
# free memory after each chunk if on GPU
if image.is_cuda:
torch.cuda.empty_cache()
return (torch.cat(results, dim=0),)
+237
View File
@@ -0,0 +1,237 @@
import torch
import math
from typing import Tuple, Optional
from .libs.process import compute_chunk_size
def exec_operator_color_comp_extract(
image: torch.Tensor,
component: str,
invert: bool = False,
linear: bool = False,
mask: Optional[torch.Tensor] = None
) -> Tuple[torch.Tensor]:
"""
Extract a specific colour component from an image batch and return it as a mask tensor.
The function supports the following colour spaces:
RGB (red, green, blue), HSV (hue, saturation, value), HSL (saturation, lightness),
CMYK (cyan, magenta, yellow, key), Y'CbCr (Y', Cb, Cr), CIE LAB (L, A, B),
CIE LCH (C(ab), H(ab)), and Alpha.
Parameters
----------
image : torch.Tensor
Input image batch with shape ``(B, H, W, C)`` where ``C`` is 3 (RGB) or
4 (RGBA). When a 3‑D tensor ``(H, W, C)`` is given it is treated as a
batch of size 1.
component : str
Name of the colour component to extract (case‑insensitive). Valid values
are those listed in the ``INPUT_TYPES`` of the calling node.
invert : bool
If ``True``, the output mask is inverted (``1 - value``) after the mask is
applied.
linear : bool
If ``True``, the extracted component is kept in linear light. When
``False`` (default) a gamma encoding (sRGB transfer function) is applied
to the raw component values before the mask is multiplied.
mask : torch.Tensor or None
Optional per‑pixel strength mask with shape ``(B, H, W)`` or ``(B, H, W, 1)``.
Must match the batch size and spatial dimensions of ``image``. If ``None``,
the extracted component is used as is (subject to ``linear`` and ``invert``).
Returns
-------
Tuple[torch.Tensor]
A single‑element tuple containing the result mask of shape ``(B, H, W)``,
with values in the range ``[0, 1]``.
Raises
------
TypeError
If ``image`` or ``mask`` is not a ``torch.Tensor``.
ValueError
If the shapes of ``image`` or ``mask`` are invalid or incompatible.
"""
# ---------- input validation ----------
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 have shape (B,H,W,C) or (H,W,C), got {image.shape}"
)
if image.dim() == 3:
image = image.unsqueeze(0) # treat as batch size 1
if mask is not None:
if not isinstance(mask, torch.Tensor):
raise TypeError("mask must be a torch.Tensor or None")
# unify mask shape to (B, H, W)
if mask.dim() == 4:
mask = mask.squeeze(-1)
if mask.dim() != 3:
raise ValueError(
f"mask must be (B,H,W) or (B,H,W,1), got {mask.shape}"
)
if mask.shape[0] != image.shape[0] or mask.shape[1:3] != image.shape[1:3]:
raise ValueError(
"mask batch and spatial dimensions must match those of image"
)
# ---------- early exit for empty input / all‑zero mask ----------
if image.shape[0] == 0:
return (torch.empty(0, image.shape[1], image.shape[2],
device=image.device, dtype=torch.float32),)
if mask is not None and mask.eq(0).all():
# result will be all zeros or all ones depending on invert
result = torch.ones_like(mask) if invert else torch.zeros_like(mask)
return (result,)
# ---------- determine GPU‑friendly chunk size ----------
if image.is_cuda and torch.cuda.is_available():
chunk_size = compute_chunk_size(image, mask)
if not isinstance(chunk_size, int) or chunk_size <= 0:
chunk_size = image.shape[0]
else:
chunk_size = image.shape[0]
chunk_size = max(1, min(chunk_size, image.shape[0]))
comp = component.strip().lower()
results = []
# ---------- chunked processing loop ----------
for start in range(0, image.shape[0], chunk_size):
end = min(start + chunk_size, image.shape[0])
img_chunk = image[start:end]
m_chunk = mask[start:end] if mask is not None else None
# --- colour space conversion (same as original) ---
rgb = img_chunk[..., :3]
r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
if comp == "rgb red":
res = r
elif comp == "rgb green":
res = g
elif comp == "rgb blue":
res = b
elif comp.startswith("hsv"):
maxc = torch.max(rgb, dim=-1).values
minc = torch.min(rgb, dim=-1).values
diff = maxc - minc + 1e-8
if comp == "hsv value":
res = maxc
elif comp == "hsv saturation":
res = diff / (maxc + 1e-8)
elif comp == "hsv hue":
hue = torch.zeros_like(maxc)
mask_nonzero = diff > 1e-8
r_eq = (maxc == r) & mask_nonzero
g_eq = (maxc == g) & mask_nonzero
b_eq = (maxc == b) & mask_nonzero
hue_r = ((g - b) / diff) % 6.0
hue_g = ((b - r) / diff) + 2.0
hue_b = ((r - g) / diff) + 4.0
hue = torch.where(r_eq, hue_r, hue)
hue = torch.where(g_eq, hue_g, hue)
hue = torch.where(b_eq, hue_b, hue)
res = (hue / 6.0) % 1.0
else:
res = maxc
elif comp.startswith("hsl"):
maxc = torch.max(rgb, dim=-1).values
minc = torch.min(rgb, dim=-1).values
l = (maxc + minc) * 0.5
if comp == "hsl lightness":
res = l
elif comp == "hsl saturation":
denom = 1 - torch.abs(2 * l - 1) + 1e-8
res = (maxc - minc) / denom
else:
res = l
elif comp.startswith("cmyk"):
k = 1.0 - torch.max(rgb, dim=-1).values
denom = 1.0 - k + 1e-8
if comp == "cmyk cyan":
res = (1.0 - r - k) / denom
elif comp == "cmyk magenta":
res = (1.0 - g - k) / denom
elif comp == "cmyk yellow":
res = (1.0 - b - k) / denom
elif comp == "cmyk key":
res = k
else:
res = k
elif "ycbcr" in comp or "y'cbcr" in comp:
y = 0.299 * r + 0.587 * g + 0.114 * b
if "cb" in comp:
res = (b - y) * 0.564 + 0.5
elif "cr" in comp:
res = (r - y) * 0.713 + 0.5
else:
res = y
elif comp.startswith("lab") or comp.startswith("lch"):
def f(t):
return torch.where(t > 0.008856, t ** (1/3), 7.787 * t + 16/116)
rl = torch.where(r > 0.04045, ((r + 0.055) / 1.055) ** 2.4, r / 12.92)
gl = torch.where(g > 0.04045, ((g + 0.055) / 1.055) ** 2.4, g / 12.92)
bl = torch.where(b > 0.04045, ((b + 0.055) / 1.055) ** 2.4, b / 12.92)
x = 0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl
y = 0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl
z = 0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl
xn, yn, zn = 0.95047, 1.0, 1.08883
x, y, z = x/xn, y/yn, z/zn
fx, fy, fz = f(x), f(y), f(z)
a = 500 * (fx - fy)
bb = 200 * (fy - fz)
if comp == "lab l":
res = (116 * fy - 16) / 100
elif comp == "lab a":
res = a / 128 + 0.5
elif comp == "lab b":
res = bb / 128 + 0.5
elif comp == "lch c(ab)":
res = torch.sqrt(a*a + bb*bb) / 128
elif comp == "lch h(ab)":
res = (torch.atan2(bb, a) / (2 * math.pi)) % 1.0
else:
res = (116 * fy - 16) / 100
elif comp == "alpha":
res = img_chunk[..., 3] if img_chunk.shape[-1] == 4 else torch.ones_like(r)
else:
res = r # fallback
# --- post‑processing (identical to original) ---
res = res.clamp(0, 1)
if not linear:
res = torch.where(
res <= 0.0031308,
12.92 * res,
1.055 * torch.pow(res, 1/2.4) - 0.055
).clamp(0, 1)
if m_chunk is not None:
res = res * m_chunk
if invert:
res = 1.0 - res
results.append(res)
final_result = torch.cat(results, dim=0)
return (final_result,)
+142
View File
@@ -0,0 +1,142 @@
import torch
from typing import Tuple, Optional
# Assume this import is available in the target environment
from .libs.process import compute_chunk_size
def exec_operator_color_comp_mono_mixer(
image: torch.Tensor,
preserve_luminosity: bool,
red_multiplier: float,
green_multiplier: float,
blue_multiplier: float,
mask: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor]:
"""
Apply a channel mixer that converts an RGB or RGBA image to grayscale using
per‑channel multipliers, optionally normalised to preserve perceptual luminance.
The operation mimics GIMP's Colors → Components → Mono Mixer. If a mask is
supplied, it acts as a per‑pixel strength factor: the grayscale result is
blended with the original image according to the mask values. An existing
alpha channel is always kept unchanged.
Processing runs on the same device as the input tensor. To avoid exhausting
GPU memory the batch is automatically split into chunks whose size is
determined by :func:`compute_chunk_size`.
Args:
image: Input image tensor [B, H, W, C], C=3 (RGB) or C=4 (RGBA).
Values must lie in [0, 1].
preserve_luminosity: If True, the three multipliers are normalised so that
their absolute sum equals 1.
red_multiplier: Coefficient for the red channel [-5.0, 5.0].
green_multiplier: Coefficient for the green channel [-5.0, 5.0].
blue_multiplier: Coefficient for the blue channel [-5.0, 5.0].
mask: Optional mask tensor [B, H, W] or [B, H, W, 1].
Values in [0, 1], 0 = no effect, 1 = full effect.
Returns:
Tuple containing a single tensor [B, H, W, C] of the same channel count
as the input, with colour values in [0, 1].
Raises:
ValueError: If the input tensor does not have shape [B, H, W, 3] or
[B, H, W, 4], or if the mask dimensions / batch size are
incompatible.
"""
# ------------------------------------------------------------------
# 1. Input validation
# ------------------------------------------------------------------
if image.dim() != 4 or image.shape[-1] not in (3, 4):
raise ValueError(
f"image must be a 4D tensor [B,H,W,C] with C=3 or 4, got shape {tuple(image.shape)}"
)
B, H, W, C = image.shape
has_alpha = (C == 4)
# ------------------------------------------------------------------
# 2. Mask validation & early‑exit conditions
# ------------------------------------------------------------------
if mask is not None:
if mask.dim() == 3:
# [B, H, W] -> [B, H, W, 1]
mask = mask.unsqueeze(-1)
elif mask.dim() == 4:
if mask.shape[-1] != 1:
raise ValueError(
f"mask must have shape [B,H,W] or [B,H,W,1], got {tuple(mask.shape)}"
)
else:
raise ValueError(
f"mask must be 3D or 4D, got {mask.dim()}D with shape {tuple(mask.shape)}"
)
if mask.shape[0] != B:
raise ValueError(
f"Batch size mismatch: image has {B}, mask has {mask.shape[0]}"
)
# Early exit: no pixels are affected
if mask.max() == 0:
return (image,)
# Early exit: empty batch
if B == 0:
return (image,)
# ------------------------------------------------------------------
# 3. Chunk size for VRAM‑friendly processing
# ------------------------------------------------------------------
chunk_size = compute_chunk_size(image, mask)
# ------------------------------------------------------------------
# 4. Per‑chunk processing
# ------------------------------------------------------------------
results = []
for start in range(0, B, chunk_size):
end = min(start + chunk_size, B)
img_chunk = image[start:end]
msk_chunk = mask[start:end] if mask is not None else None
# --- normalise multipliers (GEGL behaviour) ---
if preserve_luminosity:
m_sum = red_multiplier + green_multiplier + blue_multiplier
norm_factor = 1.0 if m_sum == 0.0 else abs(1.0 / m_sum)
else:
norm_factor = 1.0
r = red_multiplier * norm_factor
g = green_multiplier * norm_factor
b = blue_multiplier * norm_factor
# --- grayscale via dot product on RGB channels ---
gray = img_chunk[..., 0] * r + img_chunk[..., 1] * g + img_chunk[..., 2] * b
gray = torch.clamp(gray, 0.0, 1.0)
# --- build result RGB (all channels equal) ---
gray_rgb = torch.stack([gray, gray, gray], dim=-1) # [chunk, H, W, 3]
# --- alpha preservation ---
if has_alpha:
alpha = img_chunk[..., 3:4]
res_chunk = torch.cat([gray_rgb, alpha], dim=-1)
else:
res_chunk = gray_rgb
# --- mask blending (RGB channels only) ---
if msk_chunk is not None:
# msk_chunk is [chunk, H, W, 1], broadcast to RGB
res_chunk[..., :3] = (
img_chunk[..., :3] * (1.0 - msk_chunk) + gray_rgb * msk_chunk
)
results.append(res_chunk)
# ------------------------------------------------------------------
# 5. Reassemble full batch
# ------------------------------------------------------------------
final = torch.cat(results, dim=0)
return (final,)
+151
View File
@@ -0,0 +1,151 @@
import torch
import torch.nn.functional as F
from typing import Tuple
def exec_operator_color_desat_color_2_gray(
image: torch.Tensor,
radius: int,
samples: int,
iterations: int,
enhance_shadows: bool,
mask: torch.Tensor = None,
) -> Tuple[torch.Tensor]:
"""
GIMP-style Color to Gray using STRESS stochastic sampling.
Fully GPU-accelerated: all random offset sampling is vectorised,
no Python loops over pixels or samples.
"""
device = image.device
B, H, W, C = image.shape
# Convert to [B, C, H, W] for grid_sample
img = image.permute(0, 3, 1, 2).to(device)
# Initial grayscale: luminance (BT.709)
rgb_weights = torch.tensor([0.2126, 0.7152, 0.0722], device=device).view(1, 3, 1, 1)
gray = (img * rgb_weights).sum(dim=1, keepdim=True) # [B, 1, H, W]
# Tiny radius → just return luminance
if radius < 2:
gray = gray.clamp(0, 1)
result = gray.expand(-1, 3, -1, -1).permute(0, 2, 3, 1)
if mask is not None:
# apply mask if provided (even in early exit)
if mask.dim() == 3:
mask = mask.unsqueeze(-1)
elif mask.dim() == 2:
mask = mask.unsqueeze(0).unsqueeze(-1)
mask = mask.to(device)
result = image * (1 - mask) + result * mask
return (result,)
# Precompute normalisation factors for grid_sample
# (we'll use align_corners=False, so pixel centres at (i+0.5)/W-0.5 etc.)
# For grid_sample with align_corners=False, coordinates are in [-1, 1]
# where -1 maps to the left/top border of the first pixel,
# 1 maps to the right/bottom border of the last pixel.
# Pixel i's centre is at (i + 0.5) / W * 2 - 1 in x.
# But we need offsets in pixel units, so we convert pixel offsets to
# normalised grid offsets by dividing by (W-1) and multiplying by 2,
# then add to the base grid of pixel centres.
# A simpler way: generate a base grid of pixel centres, then add
# normalised random offsets. This is done below.
# Create a base grid of pixel coordinates (x, y) for the whole image.
# Shape: [1, H, W, 2] (batch=1)
ys = torch.linspace(-1, 1, H, device=device)
xs = torch.linspace(-1, 1, W, device=device)
grid_y, grid_x = torch.meshgrid(ys, xs, indexing="ij") # both [H, W]
base_grid = torch.stack([grid_x, grid_y], dim=-1).unsqueeze(0) # [1, H, W, 2]
# Expand to batch size
base_grid = base_grid.expand(B, H, W, 2).clone() # we'll add offsets
# Normalise radius from pixels to [-1,1] scale
# 1 pixel in image space corresponds to (2 / (W-1)) in normalised coords (for x)
# but we need to handle both directions; use the smaller divisor to be safe
# or use isotropic scaling. We'll just use W-1 for x and H-1 for y.
rx = (radius / (W - 1)) * 2 if W > 1 else 0.0
ry = (radius / (H - 1)) * 2 if H > 1 else 0.0
for _ in range(iterations):
# Generate random offsets for all pixels and samples at once
# Shape: [B, H, W, samples, 2]
offsets = torch.zeros(B, H, W, samples, 2, device=device)
offsets[..., 0].uniform_(-rx, rx) # x offset
offsets[..., 1].uniform_(-ry, ry) # y offset
# Build the full grid for each sample by adding offsets to base_grid
# base_grid: [B, H, W, 1, 2] + offsets: [B, H, W, S, 2]
sample_grids = base_grid.unsqueeze(3) + offsets # [B, H, W, S, 2]
# --------------------------
# Sample the colour image at all offsets in one go
# We need to call grid_sample with a grid of shape [N, H_out, W_out, 2].
# Here N = B * S, H_out = H, W_out = W.
# Reshape sample_grids to [B*S, H, W, 2]
grid_color = sample_grids.permute(0, 3, 1, 2, 4).reshape(B * samples, H, W, 2)
# Replicate the image S times along the batch dimension
img_rep = img.repeat(samples, 1, 1, 1) # [B*S, C, H, W]
sampled_col = F.grid_sample(
img_rep, grid_color, mode='bilinear',
padding_mode='border', align_corners=False
) # [B*S, C, H, W]
# Reshape back to [B, S, C, H, W] then to [B, C, H, W, S]
sampled_col = sampled_col.reshape(B, samples, C, H, W).permute(0, 2, 3, 4, 1) # [B, C, H, W, S]
# --------------------------
# Sample the current grayscale at the same offsets
# grid_sample expects input with channels, so repeat gray to B*S batch
gray_rep = gray.repeat(samples, 1, 1, 1) # [B*S, 1, H, W]
sampled_gray = F.grid_sample(
gray_rep, grid_color, mode='bilinear',
padding_mode='border', align_corners=False
) # [B*S, 1, H, W]
sampled_gray = sampled_gray.reshape(B, samples, 1, H, W).permute(0, 2, 3, 4, 1) # [B, 1, H, W, S]
# --------------------------
# Compute envelopes and contrast
min_col, _ = sampled_col.min(dim=-1) # [B, C, H, W]
max_col, _ = sampled_col.max(dim=-1)
color_range = max_col - min_col
color_contrast = torch.norm(color_range, dim=1, keepdim=True) # [B, 1, H, W]
min_gray, _ = sampled_gray.min(dim=-1) # [B, 1, H, W]
max_gray, _ = sampled_gray.max(dim=-1)
gray_range = max_gray - min_gray
gray_mean = sampled_gray.mean(dim=-1) # [B, 1, H, W]
# STRESS scaling
eps = 1e-6
scale = color_contrast / (gray_range + eps)
scale = scale.clamp(0.5, 2.0)
# Update gray: local adjustment
gray_new = gray_mean + (gray - gray_mean) * scale
if enhance_shadows:
shadow_boost = 1.0 + 0.3 * (1.0 - gray_new)
gray_new = gray_new * shadow_boost
gray = gray_new.clamp(0, 1)
# Convert to 3‑channel RGB
result = gray.expand(-1, 3, -1, -1).permute(0, 2, 3, 1)
# Apply mask if provided
if mask is not None:
if mask.dim() == 3:
mask = mask.unsqueeze(-1) # [B, H, W] → [B, H, W, 1]
elif mask.dim() == 2:
mask = mask.unsqueeze(0).unsqueeze(-1) # [H, W] → [1, H, W, 1]
mask = mask.to(device)
result = image * (1 - mask) + result * mask
return (result,)
+138
View File
@@ -0,0 +1,138 @@
import torch
from typing import Tuple, Optional
from .libs.process import compute_chunk_size
def exec_operator_color_desat_desturation(
image: torch.Tensor,
mode: str,
mask: Optional[torch.Tensor] = None
) -> Tuple[torch.Tensor]:
"""
Desaturate an RGB or RGBA image batch using one of five GIMP modes.
Modes and formulas (based on GIMP source code):
- "Luminance": (0.22 * R) + (0.72 * G) + (0.06 * B)
- "Luma": (0.22 * R) + (0.72 * G) + (0.06 * B) (non‑linearized)
- "Lightness (HSL)": 0.5 * (max(R,G,B) + min(R,G,B))
- "Avg (HSI Intensity)": (R + G + B) / 3
- "Value (HSV)": max(R,G,B)
Processing is performed in chunks along the batch dimension to limit
GPU memory usage. An optional mask can be supplied to blend between the
original and the desaturated image on a per‑pixel basis. If the mask is
entirely zero the input is returned unchanged; if it is entirely one the
blending step is skipped, providing an early‑exit optimisation.
Alpha channels (RGBA input) are preserved and passed through unchanged.
Args:
image: Input image tensor with shape [B, H, W, C], C = 3 or 4,
values in [0, 1].
mode: Desaturation mode (case‑insensitive).
mask: Optional mask tensor with shape [B, H, W] or [B, H, W, 1],
values in [0, 1]. When provided, it controls the blend
strength per pixel.
Returns:
A tuple containing the desaturated image tensor with the same
shape as the input.
Raises:
ValueError: If the image does not have 3 or 4 channels, if the mask
shape is invalid, or if an unknown mode is given.
"""
# ---------- Input validation ----------
if image.dim() != 4:
raise ValueError(
f"Expected image tensor of shape [B, H, W, C], got {image.shape}"
)
C = image.shape[-1]
if C not in (3, 4):
raise ValueError(
f"Expected 3 or 4 colour channels, got {C}"
)
# Work in float32 throughout (original code always converts to float)
img = image.float()
B, H, W, _ = img.shape
# ---------- Mask validation & normalisation ----------
if mask is not None:
if mask.dim() == 3:
mask = mask.unsqueeze(-1) # [B,H,W] -> [B,H,W,1]
elif mask.dim() == 4 and mask.shape[-1] == 1:
pass # already [B,H,W,1]
else:
raise ValueError(
f"Mask must be [B, H, W] or [B, H, W, 1], got {mask.shape}"
)
if mask.shape[0] != B or mask.shape[1] != H or mask.shape[2] != W:
raise ValueError(
f"Mask dimensions {mask.shape} do not match image {img.shape}"
)
# ---------- Early exits ----------
if mask is not None:
if torch.all(mask == 0):
# No desaturation needed anywhere
return (img,)
# Flag: if every pixel is fully desaturated we can skip blending later
all_ones = torch.all(mask == 1)
else:
# No mask → fully desaturate (identical to all_ones == True)
all_ones = True
# ---------- Chunked processing ----------
mode_lower = mode.lower()
if mode_lower not in ("luminance", "luma", "lightness (hsl)",
"avg (hsi intensity)", "average", "value (hsv)"):
raise ValueError(f"Unknown desaturation mode: {mode}")
chunk_size = compute_chunk_size(image, mask) # returns int
results = []
for start in range(0, B, chunk_size):
end = min(start + chunk_size, B)
# Slice batch
img_chunk = img[start:end]
mask_chunk = mask[start:end] if mask is not None else None
# Extract RGB channels (drop alpha for calculation)
r = img_chunk[..., 0]
g = img_chunk[..., 1]
b = img_chunk[..., 2]
# Compute greyscale according to the chosen mode
if mode_lower in ("luminance", "luma"):
gray = 0.22 * r + 0.72 * g + 0.06 * b
elif mode_lower == "lightness (hsl)":
max_rgb = torch.maximum(torch.maximum(r, g), b)
min_rgb = torch.minimum(torch.minimum(r, g), b)
gray = (max_rgb + min_rgb) * 0.5
elif mode_lower in ("avg (hsi intensity)", "average"):
gray = (r + g + b) / 3.0
elif mode_lower == "value (hsv)":
gray = torch.maximum(torch.maximum(r, g), b)
# Expand greyscale to three channels
gray_rgb = gray.unsqueeze(-1).expand_as(img_chunk)
# Blend with original using the mask (if needed)
if mask_chunk is not None and not all_ones:
# Per‑pixel blend
result_chunk = img_chunk * (1.0 - mask_chunk) + gray_rgb * mask_chunk
else:
# No blending required (no mask, or all_ones == True)
result_chunk = gray_rgb
# Reattach the alpha channel if present in the input
if C == 4:
result_chunk = torch.cat([result_chunk, img_chunk[..., 3:4]], dim=-1)
results.append(result_chunk)
final_image = torch.cat(results, dim=0)
return (final_image,)
+142
View File
@@ -0,0 +1,142 @@
import torch
from typing import Tuple
from .libs.process import compute_chunk_size
def exec_operator_color_desat_sepia(
image: torch.Tensor,
strength: float,
srgb: bool,
mask: torch.Tensor | None = None,
) -> Tuple[torch.Tensor]:
"""
Apply a GIMP‑style sepia desaturation to an image batch.
The effect interpolates between the original image (strength=0) and full
sepia toning (strength=1). When a mask is supplied it controls the
per‑pixel blend strength: the sepia colour is applied only where the mask
is non‑zero, leaving the rest unchanged. The mask’s values must be in
[0,1].
**Supported formats**
* Input / output: RGB (3 channels) or RGBA (4 channels).
* Alpha (if present) is **never** modified by the sepia matrix or the
mask – it passes through unchanged.
Args:
image: [B, H, W, C] float tensor in [0,1], C=3 or 4.
strength: 0.0 → no effect, 1.0 → full sepia.
srgb: Reserved for future colour‑space handling; currently unused.
mask: Optional float tensor in [0,1].
Shape [B, H, W] or [B, H, W, 1]. Batch size must match
*image*, spatial dimensions must be H×W.
Returns:
A tuple containing a single [B, H, W, C] float tensor with the
modified image.
"""
# ---------- 1. Input validation & early exit ----------
if not isinstance(image, torch.Tensor):
raise TypeError("`image` must be a torch.Tensor")
if image.dim() != 4:
raise ValueError(
f"`image` must be 4‑dimensional [B,H,W,C], got {image.dim()}D"
)
C = image.shape[-1]
if C not in (3, 4):
raise ValueError(
f"Only RGB (3) or RGBA (4) channels are supported, got {C}"
)
strength = max(0.0, min(1.0, float(strength)))
if strength == 0.0:
return (image,) # no change needed
B, H, W, C = image.shape
if mask is not None:
if not isinstance(mask, torch.Tensor):
raise TypeError("`mask` must be a torch.Tensor")
if mask.dim() not in (3, 4):
raise ValueError(
f"`mask` must be 3‑ or 4‑dimensional, got {mask.dim()}D"
)
if mask.shape[0] != B:
raise ValueError(
f"Batch size mismatch: image has {B}, mask has {mask.shape[0]}"
)
if mask.shape[1:3] != (H, W):
raise ValueError(
f"Spatial size mismatch: image is {H}×{W}, "
f"mask is {mask.shape[1]}×{mask.shape[2]}"
)
# ---------- 2. Chunked processing ----------
chunk_size = compute_chunk_size(image, mask)
if chunk_size <= 0:
chunk_size = B # fallback – whole batch at once
chunks = []
for start in range(0, B, chunk_size):
end = min(start + chunk_size, B)
im_chunk = image[start:end] # [chunk, H, W, C]
mask_chunk = mask[start:end] if mask is not None else None
chunks.append(_sepia_chunk(im_chunk, strength, srgb, mask_chunk))
result = torch.cat(chunks, dim=0)
return (result,)
def _sepia_chunk(
im: torch.Tensor,
strength: float,
srgb: bool, # kept for signature compatibility
mask: torch.Tensor | None,
) -> torch.Tensor:
"""Process a sub‑batch (already validated)."""
b, h, w, c = im.shape
has_alpha = c == 4
if has_alpha:
alpha = im[..., 3:4] # preserve original alpha
rgb = im[..., :3]
else:
alpha = None
rgb = im
# ----- Build interpolated sepia matrix -----
s = strength
full = torch.tensor(
[[0.393, 0.769, 0.189],
[0.349, 0.686, 0.168],
[0.272, 0.534, 0.131]],
dtype=rgb.dtype, device=rgb.device,
)
eye = torch.eye(3, dtype=rgb.dtype, device=rgb.device)
mat = eye + (full - eye) * s # [3, 3]
# ----- Apply matrix to RGB channels -----
flat = rgb.reshape(-1, 3) # [b*h*w, 3]
sepia_flat = torch.mm(flat, mat.T) # [b*h*w, 3]
sepia_rgb = sepia_flat.reshape(b, h, w, 3)
# ----- Blend with mask (only on RGB) -----
if mask is not None:
# Bring mask to [b, h, w, 1]
if mask.dim() == 3:
mask_blend = mask.unsqueeze(-1)
else:
mask_blend = mask # already [b, h, w, 1]
mask_blend = mask_blend.to(
dtype=rgb.dtype, device=rgb.device
).clamp_(0.0, 1.0)
# Broadcast across the 3 colour channels
rgb_result = rgb * (1 - mask_blend) + sepia_rgb * mask_blend
else:
rgb_result = sepia_rgb
# ----- Reattach alpha if it was present -----
if has_alpha:
return torch.cat([rgb_result, alpha], dim=-1)
return rgb_result
+136
View File
@@ -0,0 +1,136 @@
import torch
from .libs.process import compute_chunk_size
def exec_operator_color_exposure(
image: torch.Tensor,
black_level: float,
exposure: float,
mask: torch.Tensor = None,
) -> torch.Tensor:
"""
Apply a GEGL‑style exposure adjustment to an image batch, with optional
mask blending and full alpha‑channel preservation.
Exposure is modeled as:
white = 2^(-exposure)
gain = 1 / (white - black_level) (clamped for numerical stability)
result = (image - black_level) * gain
When a mask is provided, the adjusted image is blended with the original
using the mask as the per‑pixel mixing weight:
output = image * (1 - mask) + result * mask
Args:
image (torch.Tensor): Input image tensor of shape ``(B, H, W, C)``,
with C ∈ {1, 3, 4}.
black_level (float): Black level offset (clamped to avoid division
by zero).
exposure (float): Exposure value (stops). 0 → no change, >0 brighter,
<0 darker.
mask (torch.Tensor, optional): Blend mask of shape ``(B, H, W)`` or
``(1, H, W)``. If omitted, no blending.
Returns:
torch.Tensor: Exposure‑adjusted image tensor with the same shape and
dtype as the input, but always float32 internally.
"""
# --- Input validation ----------------------------------------------------
if not isinstance(image, torch.Tensor):
raise ValueError("`image` must be a torch.Tensor")
if image.dim() != 4:
raise ValueError(
f"`image` must have 4 dimensions (B,H,W,C), got {image.dim()}"
)
if image.shape[-1] not in (1, 3, 4):
raise ValueError(
f"Unsupported number of channels: {image.shape[-1]}. "
"Expected 1, 3, or 4."
)
if mask is not None:
if not isinstance(mask, torch.Tensor):
raise ValueError("`mask` must be a torch.Tensor")
if mask.dim() not in (3, 4):
raise ValueError(
f"`mask` must have 3 or 4 dimensions, got {mask.dim()}"
)
if mask.shape[0] not in (1, image.shape[0]):
raise ValueError(
f"Batch size of mask ({mask.shape[0]}) must be 1 or match "
f"image batch size ({image.shape[0]})"
)
# --- Early exit (identity transform) -------------------------------------
if exposure == 0.0 and black_level == 0.0:
# Even with a mask, the blend would be identity → return original
return image
# --- Device & precision setup --------------------------------------------
image = image.to(dtype=torch.float32)
device = image.device
# --- Alpha channel separation (if any) -----------------------------------
has_alpha = image.shape[-1] == 4
if has_alpha:
rgb = image[..., :3] # (B, H, W, 3)
alpha = image[..., 3:4] # (B, H, W, 1)
else:
rgb = image # operate on all channels
alpha = None
B, H, W, C_rgb = rgb.shape
# --- Mask preparation (batched or single) --------------------------------
mask_tensor = None
if mask is not None:
mask_tensor = mask.to(device)
# Normalise mask shape to (B_mask, H, W, 1)
if mask_tensor.dim() == 3:
mask_tensor = mask_tensor.unsqueeze(-1) # (B, H, W, 1)
elif mask_tensor.dim() == 4 and mask_tensor.shape[1] == 1:
# ComfyUI sometimes uses (B, 1, H, W)
mask_tensor = mask_tensor.permute(0, 2, 3, 1) # (B, H, W, 1)
# If a single mask is given, broadcast across the batch later
single_mask = mask_tensor.shape[0] == 1
# --- VRAM‑aware chunked processing ---------------------------------------
chunk_size = compute_chunk_size(image) # based on full image (includes alpha if present)
results = []
for start in range(0, B, chunk_size):
end = min(start + chunk_size, B)
rgb_chunk = rgb[start:end] # (chunk_B, H, W, C_rgb)
# --- Apply GEGL exposure transform (colour channels only) ------------
white = torch.exp2(torch.tensor(-exposure, device=device))
diff = torch.clamp(white - black_level, min=1e-6)
gain = 1.0 / diff
transformed = (rgb_chunk - black_level) * gain
# --- Optional mask blending on colour channels -----------------------
if mask_tensor is not None:
if single_mask:
# Use the single mask for every chunk
mask_chunk = mask_tensor.expand(end - start, -1, -1, -1) # (chunk_B, H, W, 1)
else:
mask_chunk = mask_tensor[start:end] # (chunk_B, H, W, 1)
# Expand to match rgb_chunk shape (broadcast over channels)
mask_expanded = mask_chunk.expand_as(rgb_chunk)
result_chunk = rgb_chunk * (1.0 - mask_expanded) + transformed * mask_expanded
else:
result_chunk = transformed
# --- Reattach alpha channel if present -------------------------------
if has_alpha:
alpha_chunk = alpha[start:end] # (chunk_B, H, W, 1)
result_chunk = torch.cat([result_chunk, alpha_chunk], dim=-1)
results.append(result_chunk)
return torch.cat(results, dim=0)
+154
View File
@@ -0,0 +1,154 @@
import torch
import torch.nn.functional as F
from .libs.color import rgb_to_lch_torch, lch_to_rgb_torch
def exec_operator_color_hue_chroma(
image: torch.Tensor,
hue: float,
chroma: float,
lightness: float,
mask: torch.Tensor = None,
) -> torch.Tensor:
"""
Apply hue, chroma and lightness adjustments in LCH color space.
The image is converted from RGB to LCH, the requested adjustments are
applied, then converted back to RGB. Hue and chroma adjustments are
skipped for achromatic pixels while lightness is always adjusted.
If a mask is supplied, the adjusted image is blended with the original
image according to the mask values.
Supports RGB and RGBA images and batched processing.
"""
if not isinstance(image, torch.Tensor):
raise TypeError("image must be a torch.Tensor")
if image.ndim != 4:
raise ValueError(
"image must have shape (B,H,W,C)"
)
if image.shape[-1] not in (3, 4):
raise ValueError(
"image must have 3 (RGB) or 4 (RGBA) channels"
)
if not image.is_floating_point():
raise TypeError(
"image must be floating point"
)
rgb = image[..., :3]
alpha = image[..., 3:] if image.shape[-1] == 4 else None
device = rgb.device
dtype = rgb.dtype
if mask is not None:
if not isinstance(mask, torch.Tensor):
raise TypeError("mask must be a torch.Tensor")
mask = mask.to(device=device, dtype=dtype)
if mask.ndim == 2:
mask = mask.unsqueeze(0).unsqueeze(-1)
elif mask.ndim == 3:
mask = mask.unsqueeze(-1)
elif mask.ndim != 4:
raise ValueError(
"mask must have shape (H,W), (B,H,W) or (B,H,W,1)"
)
if mask.shape[-1] != 1:
mask = mask[..., :1]
#
# Pair image batch with corresponding mask.
#
if mask.shape[0] == 1 and rgb.shape[0] > 1:
mask = mask.expand(rgb.shape[0], -1, -1, -1)
elif mask.shape[0] != rgb.shape[0]:
raise ValueError(
"mask batch size must be 1 or match image batch size"
)
if (
mask.shape[1] != rgb.shape[1]
or mask.shape[2] != rgb.shape[2]
):
mask = F.interpolate(
mask.permute(0, 3, 1, 2),
size=rgb.shape[1:3],
mode="bilinear",
align_corners=False,
).permute(0, 2, 3, 1)
EPSILON = 1e-4
#
# RGB -> LCH
#
lch = rgb_to_lch_torch(rgb)
L = lch[..., 0]
C = lch[..., 1]
H = lch[..., 2]
achromatic = torch.abs(C) < EPSILON
L_adj = L + lightness
#
# Only clone if modification is required.
#
if chroma != 0.0:
C_adj = C.clone()
C_adj.masked_scatter_(
~achromatic,
C.masked_select(~achromatic) + chroma,
)
else:
C_adj = C
if hue != 0.0:
H_adj = H.clone()
H_adj.masked_scatter_(
~achromatic,
H.masked_select(~achromatic) + hue,
)
else:
H_adj = H
C_adj = torch.clamp(C_adj, 0.0, 300.0)
H_adj = H_adj.remainder(360.0)
H_adj = torch.where(
H_adj >= 180.0,
H_adj - 360.0,
H_adj,
)
result_rgb = lch_to_rgb_torch(
torch.stack(
(L_adj, C_adj, H_adj),
dim=-1,
)
)
if mask is not None:
result_rgb = rgb * (1.0 - mask) + result_rgb * mask
if alpha is not None:
return torch.cat((result_rgb, alpha), dim=-1)
return result_rgb
+99
View File
@@ -0,0 +1,99 @@
import torch
from typing import Optional
from .libs.color import rgb_to_hsl, hsl_to_rgb
# =========================================================
# MAIN OPERATOR (GIMP-style)
# =========================================================
@torch.no_grad()
def exec_operator_color_hue_saturation(
image: torch.Tensor,
mask: Optional[torch.Tensor],
overlap: float,
data: list,
) -> torch.Tensor:
"""
Core hue/saturation/lightness adjustment with per‑colour ranges.
Parameters
----------
image : torch.Tensor, shape (B, H, W, C) – C=3 (RGB) or C=4 (RGBA)
mask : optional torch.Tensor (B, H, W) or (B, 1, H, W) – luminance mask
overlap : float (0‑100) – hue blending spread
data : list of 7 lists of 3 floats – [master, red, yellow, green, cyan, blue, magenta]
Returns
-------
torch.Tensor of same shape as `image`, with alpha (if present) unchanged.
"""
# ---------- Device and range ----------
device = image.device
image = image.clamp(0.0, 1.0)
# ---------- Handle RGBA ----------
has_alpha = image.shape[-1] == 4
if has_alpha:
rgb = image[..., :3] # (B, H, W, 3)
alpha = image[..., 3:] # (B, H, W, 1)
else:
rgb = image
# ---------- Original algorithm on RGB channels ----------
h, s, l = rgb_to_hsl(rgb) # all tensors (B, H, W)
master_h, master_l, master_s = data[0]
channels_h = torch.tensor([d[0] for d in data[1:]], device=device) # (6,)
channels_l = torch.tensor([d[1] for d in data[1:]], device=device)
channels_s = torch.tensor([d[2] for d in data[1:]], device=device)
centers = torch.tensor([0, 60, 120, 180, 240, 300], device=device) # (6,)
# ------- per‑hue weighting (computed once for all shifts) -------
h_exp = h.unsqueeze(-1) # (B, H, W, 1)
diff = (h_exp - centers + 180.0) % 360.0 - 180.0
absdiff = torch.abs(diff)
spread = max(1.0, 180.0 * overlap / 100.0)
weight = torch.clamp(1.0 - absdiff / spread, 0.0, 1.0) # (B, H, W, 6)
# ------- accumulate shifts -------
h_shift = master_h + torch.sum(weight * channels_h, dim=-1) # (B, H, W)
l_shift = (master_l / 100.0) + torch.sum(weight * (channels_l / 100.0), dim=-1)
s_shift = (master_s / 100.0) + torch.sum(weight * (channels_s / 100.0), dim=-1)
# Free the large weight tensor as soon as it is no longer needed
del weight
# ------- apply shifts -------
h = (h + h_shift) % 360.0
l = torch.clamp(l + l_shift, 0.0, 1.0)
s = torch.clamp(s + s_shift, 0.0, 1.0)
out_rgb = hsl_to_rgb(h, s, l)
# ------- free HSL tensors -------
del h, s, l, h_shift, l_shift, s_shift
# ---------- Mask blend (on RGB part only) ----------
if mask is not None:
# Normalise mask shape to (B, H, W, 1) for broadcasting
if mask.dim() == 3:
mask = mask.unsqueeze(-1) # (B, H, W, 1)
elif mask.dim() == 4 and mask.shape[1] == 1:
mask = mask.permute(0, 2, 3, 1) # (B, 1, H, W) → (B, H, W, 1)
mask = mask.to(device).expand_as(out_rgb) # broadcast to 3 channels
out_rgb = rgb * (1.0 - mask) + out_rgb * mask
# ---------- Reattach alpha if needed ----------
if has_alpha:
out = torch.cat([out_rgb, alpha], dim=-1)
else:
out = out_rgb
return out
+182
View File
@@ -0,0 +1,182 @@
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,)
+356
View File
@@ -0,0 +1,356 @@
import torch
from typing import Optional, Tuple
from .libs.process import compute_chunk_size
# ============================================================
# Utility: safe clamp
# ============================================================
def _clamp01(x):
return torch.clamp(x, 0.0, 1.0)
# ============================================================
# Core GIMP-like Levels transfer (per-channel)
# ============================================================
def _levels_transfer(
v: torch.Tensor,
in_low: float,
in_high: float,
gamma: float,
out_low: float,
out_high: float,
clamp_input: bool,
clamp_output: bool,
):
denom = (in_high - in_low)
if abs(denom) < 1e-8:
denom = 1e-8
v = (v - in_low) / denom
if clamp_input:
v = _clamp01(v)
gamma = max(gamma, 1e-6)
v = torch.pow(torch.clamp(v, min=0.0), 1.0 / gamma)
v = out_low + v * (out_high - out_low)
if clamp_output:
v = _clamp01(v)
return v
# ============================================================
# HSV conversion (needed for Value channel mode like GIMP)
# ============================================================
def _rgb_to_hsv(img: torch.Tensor):
r, g, b = img[..., 0], img[..., 1], img[..., 2]
maxc, _ = torch.max(img[..., :3], dim=-1)
minc, _ = torch.min(img[..., :3], dim=-1)
v = maxc
delta = maxc - minc
s = torch.where(maxc > 0, delta / (maxc + 1e-8), torch.zeros_like(maxc))
hue = torch.zeros_like(maxc)
mask = delta > 1e-8
rc = (maxc - r) / (delta + 1e-8)
gc = (maxc - g) / (delta + 1e-8)
bc = (maxc - b) / (delta + 1e-8)
hue = torch.where(
(maxc == r) & mask,
(bc - gc),
hue
)
hue = torch.where(
(maxc == g) & mask,
2.0 + (rc - bc),
hue
)
hue = torch.where(
(maxc == b) & mask,
4.0 + (gc - rc),
hue
)
hue = (hue / 6.0) % 1.0
return torch.stack([hue, s, v], dim=-1)
def _hsv_to_rgb(hsv: torch.Tensor):
h, s, v = hsv[..., 0], hsv[..., 1], hsv[..., 2]
h = (h % 1.0) * 6.0
i = torch.floor(h)
f = h - i
p = v * (1.0 - s)
q = v * (1.0 - s * f)
t = v * (1.0 - s * (1.0 - f))
i = i.long() % 6
r = torch.zeros_like(v)
g = torch.zeros_like(v)
b = torch.zeros_like(v)
cond = i == 0
r = torch.where(cond, v, r)
g = torch.where(cond, t, g)
b = torch.where(cond, p, b)
cond = i == 1
r = torch.where(cond, q, r)
g = torch.where(cond, v, g)
b = torch.where(cond, p, b)
cond = i == 2
r = torch.where(cond, p, r)
g = torch.where(cond, v, g)
b = torch.where(cond, t, b)
cond = i == 3
r = torch.where(cond, p, r)
g = torch.where(cond, q, g)
b = torch.where(cond, v, b)
cond = i == 4
r = torch.where(cond, t, r)
g = torch.where(cond, p, g)
b = torch.where(cond, v, b)
cond = i == 5
r = torch.where(cond, v, r)
g = torch.where(cond, p, g)
b = torch.where(cond, q, b)
return torch.stack([r, g, b], dim=-1)
# ============================================================
# Histogram (GIMP-style endpoint detection)
# ============================================================
def _histogram_bounds(channel: torch.Tensor):
flat = channel.reshape(-1)
flat = flat[torch.isfinite(flat)]
if flat.numel() == 0:
return 0.0, 1.0
lo = torch.min(flat)
hi = torch.max(flat)
return lo.item(), hi.item()
# ============================================================
# Mask blending (GEGL-style)
# ============================================================
def _apply_mask(src: torch.Tensor, dst: torch.Tensor, mask: Optional[torch.Tensor]):
if mask is None:
return dst
if mask.dim() == 3:
mask = mask.unsqueeze(-1)
mask = mask.to(dtype=src.dtype, device=src.device)
return src * (1.0 - mask) + dst * mask
# ============================================================
# Apply per-channel Levels
# ============================================================
def _apply_levels(
img: torch.Tensor,
channel: str,
in_low: float,
in_high: float,
gamma: float,
out_low: float,
out_high: float,
clamp_input: bool,
clamp_output: bool,
auto_input: bool,
mask: Optional[torch.Tensor],
):
B, H, W, C = img.shape
if auto_input:
if channel == "Value":
v = img[..., :3].max(dim=-1).values
in_low, in_high = _histogram_bounds(v)
elif channel == "Red":
in_low, in_high = _histogram_bounds(img[..., 0])
elif channel == "Green":
in_low, in_high = _histogram_bounds(img[..., 1])
elif channel == "Blue":
in_low, in_high = _histogram_bounds(img[..., 2])
elif channel == "Alpha" and C == 4:
in_low, in_high = _histogram_bounds(img[..., 3])
if channel == "Value":
hsv = _rgb_to_hsv(img[..., :3])
v = hsv[..., 2]
v2 = _levels_transfer(
v,
in_low, in_high,
gamma,
out_low, out_high,
clamp_input,
clamp_output,
)
hsv[..., 2] = v2
rgb = _hsv_to_rgb(hsv)
out = img.clone()
out[..., :3] = rgb
if C == 4:
out[..., 3] = img[..., 3]
return out
out = img.clone()
channel_map = {
"Red": 0,
"Green": 1,
"Blue": 2,
"Alpha": 3,
}
if channel in channel_map:
c = channel_map[channel]
if c < C:
processed = _levels_transfer(
img[..., c],
in_low, in_high,
gamma,
out_low, out_high,
clamp_input,
clamp_output,
)
out[..., c] = processed
return _apply_mask(img, out, mask)
# ============================================================
# FINAL OPERATOR ENTRY
# ============================================================
def exec_operator_color_levels(
image: torch.Tensor,
channel: str,
interpolation: str, # kept for API compatibility (ignored like GIMP Levels)
input_low: float,
input_high: float,
input_gamma: float,
input_clamp: bool,
output_low: float,
output_high: float,
output_clamp: bool,
auto_input: bool,
mask: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""
Apply a GIMP‑style Levels adjustment to an image tensor.
The adjustment remaps the selected channel (or the Value component of HSV)
using input low/high, gamma, and output low/high. The operation supports
batch processing with an optional per‑image mask. Input and output tensors
must be in RGB or RGBA format with values in [0,1].
Args:
image: Input tensor of shape (B, H, W, C) with C=3 (RGB) or C=4 (RGBA).
channel: Channel to modify: "Value", "Red", "Green", "Blue", or "Alpha".
interpolation: Unused, kept for API compatibility.
input_low, input_high: Input range in UI units (0‑255).
input_gamma: Gamma correction factor (>0).
input_clamp: Clamp normalized values to [0,1] before gamma.
output_low, output_high: Output range in UI units (0‑255).
output_clamp: Clamp final values to [0,1].
auto_input: If True, compute input low/high from the image histogram.
mask: Optional mask tensor of shape (B, H, W) or (B, H, W, 1) to blend
the original and adjusted images.
Returns:
Adjusted image tensor with the same shape and dtype as the input.
"""
# ------------------------------------------------------------------
# Input validation
# ------------------------------------------------------------------
if not isinstance(image, torch.Tensor):
raise TypeError("image must be a torch.Tensor")
if image.dim() != 4:
raise ValueError("image must have 4 dimensions [B, H, W, C]")
B, H, W, C = image.shape
if C not in (3, 4):
raise ValueError("image must have 3 or 4 channels (RGB or RGBA)")
if mask is not None:
if not isinstance(mask, torch.Tensor):
raise TypeError("mask must be a torch.Tensor")
if mask.dim() == 3:
mask = mask.unsqueeze(-1) # [B, H, W, 1]
elif mask.dim() == 4 and mask.shape[-1] == 1:
pass
else:
raise ValueError("mask must have shape [B, H, W] or [B, H, W, 1]")
if mask.shape[0] != B:
raise ValueError(
f"mask batch size {mask.shape[0]} does not match image batch size {B}"
)
mask = mask.to(device=image.device, dtype=image.dtype)
# ------------------------------------------------------------------
# Early exit: identity transformation
# ------------------------------------------------------------------
if (not auto_input and
input_low == 0 and input_high == 255 and input_gamma == 1.0 and
output_low == 0 and output_high == 255 and
not input_clamp and not output_clamp):
return image.clone()
# ------------------------------------------------------------------
# Normalize parameters to [0,1]
# ------------------------------------------------------------------
in_low = input_low / 255.0
in_high = input_high / 255.0
out_low = output_low / 255.0
out_high = output_high / 255.0
gamma = max(float(input_gamma), 1e-6)
# ------------------------------------------------------------------
# Auto‑input: compute global histogram bounds on the whole batch
# ------------------------------------------------------------------
if auto_input:
if channel == "Value":
v = image[..., :3].max(dim=-1).values
in_low, in_high = _histogram_bounds(v)
elif channel == "Red":
in_low, in_high = _histogram_bounds(image[..., 0])
elif channel == "Green":
in_low, in_high = _histogram_bounds(image[..., 1])
elif channel == "Blue":
in_low, in_high = _histogram_bounds(image[..., 2])
elif channel == "Alpha" and C == 4:
in_low, in_high = _histogram_bounds(image[..., 3])
else:
# Channel not available: keep default [0,1] (no effect)
in_low, in_high = 0.0, 1.0
# ------------------------------------------------------------------
# Chunked processing for VRAM management
# ------------------------------------------------------------------
chunk_size = compute_chunk_size(image, mask)
if chunk_size is None or chunk_size <= 0:
chunk_size = B
outputs = []
for start in range(0, B, chunk_size):
end = min(start + chunk_size, B)
img_chunk = image[start:end]
mask_chunk = mask[start:end] if mask is not None else None
out_chunk = _apply_levels(
img_chunk,
channel=channel,
in_low=in_low,
in_high=in_high,
gamma=gamma,
out_low=out_low,
out_high=out_high,
clamp_input=input_clamp,
clamp_output=output_clamp,
auto_input=False, # already computed globally
mask=mask_chunk,
)
outputs.append(out_chunk)
if outputs:
result = torch.cat(outputs, dim=0)
else:
result = image # should not happen for non‑empty batch
return result.to(image.dtype).clamp(0.0, 1.0)
+246
View File
@@ -0,0 +1,246 @@
import torch
import math
from typing import Tuple, Optional
from .libs.process import compute_chunk_size
# ===================================================================================
def rgb_to_hsl_torch(image: torch.Tensor) -> torch.Tensor:
"""
Convert an RGB image to HSL.
Input: (B, H, W, 3) in [0, 1]
Output: (B, H, W, 3) with H, S, L in [0, 1]
"""
r, g, b = image[..., 0], image[..., 1], image[..., 2]
max_val, _ = torch.max(image, dim=-1)
min_val, _ = torch.min(image, dim=-1)
l = (max_val + min_val) / 2.0
delta = max_val - min_val
# Saturation: 0 if delta == 0
s = torch.where(delta == 0, torch.zeros_like(delta),
delta / (1.0 - torch.abs(2.0 * l - 1.0)))
h = torch.zeros_like(l)
mask = delta != 0
r_eq_max = (max_val == r) & mask
g_eq_max = (max_val == g) & mask
# Otherwise b_eq_max (implicit)
h = torch.where(r_eq_max, ((g - b) / delta) % 6.0, h)
h = torch.where(g_eq_max, ((b - r) / delta) + 2.0, h)
h = torch.where(~r_eq_max & ~g_eq_max & mask, ((r - g) / delta) + 4.0, h)
h = h / 6.0
h = torch.where(h < 0, h + 1.0, h)
h = torch.where(h >= 1.0, h - 1.0, h)
return torch.stack([h, s, l], dim=-1)
def hsl_to_rgb_torch(image: torch.Tensor) -> torch.Tensor:
"""
Convert an HSL image to RGB.
Input: (B, H, W, 3) with H, S, L in [0, 1]
Output: (B, H, W, 3) in [0, 1]
"""
h, s, l = image[..., 0], image[..., 1], image[..., 2]
c = (1.0 - torch.abs(2.0 * l - 1.0)) * s
x = c * (1.0 - torch.abs((h * 6.0) % 2.0 - 1.0))
m = l - c / 2.0
sector = (h * 6.0).to(torch.int64) % 6
zeros = torch.zeros_like(h)
r = torch.where(sector == 0, c,
torch.where(sector == 1, x,
torch.where(sector == 2, zeros,
torch.where(sector == 3, zeros,
torch.where(sector == 4, x, c)))))
g = torch.where(sector == 0, x,
torch.where(sector == 1, c,
torch.where(sector == 2, c,
torch.where(sector == 3, x,
torch.where(sector == 4, zeros, zeros)))))
b = torch.where(sector == 0, zeros,
torch.where(sector == 1, zeros,
torch.where(sector == 2, x,
torch.where(sector == 3, c,
torch.where(sector == 4, c, x)))))
return torch.stack([r + m, g + m, b + m], dim=-1)
def exec_operator_color_map_alien(
image: torch.Tensor,
model: str,
freq1: float, freq2: float, freq3: float,
shift1: float, shift2: float, shift3: float,
keep1: bool, keep2: bool, keep3: bool,
mask: Optional[torch.Tensor] = None
) -> Tuple[torch.Tensor]:
"""
Apply the Alien Map colour transformation to a batch of images.
The transformation mimics the GIMP “Alien Map” filter. For each colour
channel that is not “kept”, the operation
out = 0.5 * (1 + sin( (2*in - 1) * freq + phase ))
is applied, where *freq* = `frequency` × π and *phase* = π × `shift` / 180°.
The colour space can be either RGB or HSL; when HSL is chosen the image is
temporarily converted, transformed, and then converted back to RGB.
**Batch processing** – if `mask` is a batch, the i‑th image is processed
with the i‑th mask. A single mask is broadcast across the whole batch.
**RGB / RGBA** – 3‑channel (RGB) and 4‑channel (RGBA) images are accepted.
For RGBA the alpha channel is **preserved unchanged**. Mask blending
affects only the RGB channels.
**Early exit** – when all three `keep` flags are `True` the transformation
is an identity; the function returns the original image immediately.
**GPU acceleration & VRAM management** – all work is done on the same
device as the input tensor. To avoid out‑of‑memory errors the batch is
processed in chunks whose size is determined by
:func:`compute_chunk_size`. Spatial (tiled) splitting is **not** used.
Parameters
----------
image : torch.Tensor
Input image(s), shape ``(B, H, W, 3)`` or ``(B, H, W, 4)``, values in [0, 1].
model : str
``"RGB"`` or ``"HSL"``.
freq1, freq2, freq3 : float
Frequency multipliers (0 – 20).
shift1, shift2, shift3 : float
Phase shifts in degrees (0 – 360).
keep1, keep2, keep3 : bool
If ``True`` the corresponding channel is left unchanged.
mask : torch.Tensor, optional
Per‑pixel strength of the effect, shape ``(B, H, W)`` or ``(B, H, W, 1)``,
values in [0, 1]. The output is blended linearly:
``mask * transformed + (1 - mask) * original``.
Returns
-------
Tuple[torch.Tensor]
A single‑element tuple containing the transformed image tensor
``(B, H, W, 3)`` or ``(B, H, W, 4)``, same shape and device as the input.
Raises
------
ValueError
If the input image has an unsupported shape, or if the mask dimensions
are incompatible with the image.
"""
# ----------------------- input validation ----------------------------------
if not isinstance(image, torch.Tensor):
raise ValueError("image must be a torch.Tensor")
if image.dim() != 4 or image.shape[-1] not in (3, 4):
raise ValueError(
f"image must be 4D (B, H, W, C) with C=3 or 4, got {image.shape}"
)
batch_size, height, width, channels = image.shape
has_alpha = (channels == 4)
if mask is not None:
if not isinstance(mask, torch.Tensor):
raise ValueError("mask must be a torch.Tensor or None")
if mask.dim() == 3:
mask = mask.unsqueeze(-1) # (B, H, W, 1)
elif mask.dim() != 4 or mask.shape[-1] != 1:
raise ValueError(
f"mask must be 3D (B,H,W) or 4D (B,H,W,1), got {mask.shape}"
)
if mask.shape[1:3] != (height, width):
raise ValueError(
f"mask spatial size {mask.shape[1:3]} must match image {(height, width)}"
)
mask_batch = mask.shape[0]
if mask_batch != 1 and mask_batch != batch_size:
raise ValueError(
f"mask batch size {mask_batch} must be 1 or equal to image batch size {batch_size}"
)
# ----------------------- early exit ----------------------------------------
if all([keep1, keep2, keep3]):
# The transformation is an identity; no need to compute anything.
return (image,)
# ----------------------- prepare data --------------------------------------
img = image.float()
img = torch.clamp(img, 0.0, 1.0)
# Separate RGB and alpha (if present)
if has_alpha:
rgb = img[..., :3].clone()
alpha = img[..., 3:4].clone()
original_rgb = rgb.clone()
else:
rgb = img
alpha = None
original_rgb = img.clone() # used for mask blending later
device = rgb.device
# Colour‑space conversion (RGB → HSL) if required
if model == "HSL":
rgb = rgb_to_hsl_torch(rgb)
# Transformation constants (on correct device)
freq = torch.tensor(
[freq1 * math.pi, freq2 * math.pi, freq3 * math.pi],
dtype=torch.float32, device=device
)
phase = torch.tensor(
[math.pi * shift1 / 180.0,
math.pi * shift2 / 180.0,
math.pi * shift3 / 180.0],
dtype=torch.float32, device=device
)
keep = torch.tensor([keep1, keep2, keep3], dtype=torch.bool, device=device)
# ----------------------- chunked processing --------------------------------
chunk_size = compute_chunk_size(image, mask) # returns int
if chunk_size <= 0 or chunk_size > batch_size:
chunk_size = batch_size
out_rgb = torch.empty_like(rgb)
for start in range(0, batch_size, chunk_size):
end = min(start + chunk_size, batch_size)
rgb_chunk = rgb[start:end]
out_chunk = rgb_chunk.clone()
# Apply the Alien Map formula per channel
for i in range(3):
if not keep[i]:
val = 2.0 * rgb_chunk[..., i] - 1.0
out_chunk[..., i] = 0.5 * (1.0 + torch.sin(val * freq[i] + phase[i]))
out_chunk = torch.clamp(out_chunk, 0.0, 1.0)
# Back to RGB if we were in HSL
if model == "HSL":
out_chunk = hsl_to_rgb_torch(out_chunk)
# Blend with mask (RGB channels only)
if mask is not None:
mask_chunk = mask[start:end] # will broadcast correctly if mask batch=1
# original_rgb_chunk has the original clamped values
original_chunk = original_rgb[start:end]
out_chunk = mask_chunk * out_chunk + (1.0 - mask_chunk) * original_chunk
out_rgb[start:end] = out_chunk
# ----------------------- recombine alpha (if present) -----------------------
if has_alpha:
out = torch.cat([out_rgb, alpha], dim=-1)
else:
out = out_rgb
return (out,)
+166
View File
@@ -0,0 +1,166 @@
import torch
from typing import Optional, Tuple
from .libs.process import compute_chunk_size
# ===================================================================================
def exec_operator_color_map_color_exchange(
image: torch.Tensor,
from_color: int,
to_color: int,
red_threshold: float,
green_threshold: float,
blue_threshold: float,
mask: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor]:
"""
Color Exchange (RGB/RGBA) with soft mask, batch chunking and alpha preservation.
Pixels whose RGB components *all* lie within the given thresholds around a
source colour are recoloured to a target colour, preserving each channel's
original offset from the source::
out_channel = target_channel + (pixel_channel - source_channel)
The result is clamped to [0,1]. When a soft mask is supplied, the
recolouring is blended per‑pixel using the mask value as the interpolation
strength.
Parameters
----------
image : torch.Tensor
Batched image tensor of shape ``(B, H, W, 3)`` or ``(B, H, W, 4)``,
values in [0, 1], dtype float16/32/64.
from_color : int
Packed source colour (0xRRGGBB).
to_color : int
Packed target colour (0xRRGGBB).
red_threshold, green_threshold, blue_threshold : float
Per‑channel tolerance in [0, 1].
mask : torch.Tensor, optional
Soft mask of shape ``(B, H, W)`` or ``(B, H, W, 1)``, or a single
mask broadcastable to the batch size. Values are clamped to [0, 1]
and used as per‑pixel blend strength.
Returns
-------
Tuple[torch.Tensor]
A single‑element tuple containing the recoloured image with the same
shape and number of channels as the input.
"""
# ---- Validate input --------------------------------------------------------
if image.ndim != 4:
raise ValueError(f"image must be 4D (B,H,W,C), got shape {image.shape}")
C = image.shape[-1]
if C not in (3, 4):
raise ValueError(f"image must have 3 or 4 channels, got {C}")
if not torch.is_floating_point(image):
raise TypeError("image must be a floating point tensor")
if not (0 <= from_color <= 0xFFFFFF):
raise ValueError(f"from_color must be in [0, 0xFFFFFF], got {from_color:#x}")
if not (0 <= to_color <= 0xFFFFFF):
raise ValueError(f"to_color must be in [0, 0xFFFFFF], got {to_color:#x}")
for th, name in zip(
(red_threshold, green_threshold, blue_threshold),
("red_threshold", "green_threshold", "blue_threshold"),
):
if not (0.0 <= th <= 1.0):
raise ValueError(f"{name} must be in [0, 1], got {th}")
device = image.device
dtype = image.dtype
B = image.shape[0]
# ---- Mask validation & early exit ------------------------------------------
if mask is not None:
if mask.ndim == 3:
mask = mask.unsqueeze(-1) # (B,H,W) -> (B,H,W,1)
elif mask.ndim == 4:
if mask.shape[-1] != 1:
raise ValueError("mask with 4 dims must have last dim == 1")
else:
raise ValueError(f"mask must be 3D or 4D, got shape {mask.shape}")
# Move mask to the correct device/dtype, clamp
mask = mask.to(device=device, dtype=dtype).clamp(0.0, 1.0)
# Check batch compatibility
if mask.shape[0] != 1 and mask.shape[0] != B:
raise ValueError(
f"mask batch size {mask.shape[0]} must be 1 or match image batch size {B}"
)
# Early exit if mask is entirely zero
if mask.count_nonzero() == 0:
return (image,)
# ---- Pre‑compute colour thresholds (RGB only) --------------------------------
from_rgb = image.new_tensor([
((from_color >> 16) & 0xFF) / 255.0,
((from_color >> 8) & 0xFF) / 255.0,
(from_color & 0xFF) / 255.0,
])
to_rgb = image.new_tensor([
((to_color >> 16) & 0xFF) / 255.0,
((to_color >> 8) & 0xFF) / 255.0,
(to_color & 0xFF) / 255.0,
])
thresholds = image.new_tensor([red_threshold, green_threshold, blue_threshold])
# Broadcast dimensions: (1,1,1,3)
from_rgb = from_rgb.view(1, 1, 1, 3)
to_rgb = to_rgb.view(1, 1, 1, 3)
thresholds = thresholds.view(1, 1, 1, 3)
# ---- RGBA support: separate RGB and alpha ----------------------------------
if C == 4:
image_rgb = image[..., :3]
image_alpha = image[..., 3:4] # keep as (B,H,W,1)
else:
image_rgb = image
# ---- Determine chunk size and process in sub‑batches -----------------------
chunk_size = compute_chunk_size(image, mask)
result_chunks = []
for start in range(0, B, chunk_size):
end = min(start + chunk_size, B)
img_chunk = image_rgb[start:end] # (chunk, H, W, 3)
# Extract matching mask chunk (handle broadcast)
if mask is not None:
if mask.shape[0] == 1:
mask_chunk = mask # broadcast across chunk
else:
mask_chunk = mask[start:end]
else:
mask_chunk = None
# ----- Original colour exchange logic (applied to RGB) ------------------
diff = (img_chunk - from_rgb).abs()
match = (diff <= thresholds).all(dim=-1, keepdim=True) # (chunk,H,W,1)
exchanged = to_rgb + (img_chunk - from_rgb)
exchanged = torch.clamp(exchanged, 0.0, 1.0)
result_rgb = torch.where(match, exchanged, img_chunk)
# ----- Soft mask blending (applies only to RGB) -------------------------
if mask_chunk is not None:
# mask_chunk is (1,H,W,1) or (chunk,H,W,1) – already prepared
result_rgb = img_chunk * (1.0 - mask_chunk) + result_rgb * mask_chunk
# ----- Reassemble RGBA if needed ----------------------------------------
if C == 4:
alpha_chunk = image_alpha[start:end]
result_chunk = torch.cat([result_rgb, alpha_chunk], dim=-1)
else:
result_chunk = result_rgb
result_chunks.append(result_chunk)
# Concatenate all chunks back to a single batch tensor
result = torch.cat(result_chunks, dim=0)
return (result,)
+252
View File
@@ -0,0 +1,252 @@
import torch
from typing import Tuple, Optional
from .libs.color import hsv_to_rgb, rgb_to_hsv
from .libs.process import compute_chunk_size
# -----------------------------------------------------------------------------
# Angular helpers (unchanged)
# -----------------------------------------------------------------------------
def _cw_distance(start: float, angle: torch.Tensor) -> torch.Tensor:
"""Clockwise angular distance [0,360)."""
return (angle - start) % 360.0
def _ccw_distance(start: float, angle: torch.Tensor) -> torch.Tensor:
"""Counter-clockwise angular distance [0,360)."""
return (start - angle) % 360.0
# -----------------------------------------------------------------------------
# Core color rotate logic (identical per-pixel behaviour, no mask/alpha)
# -----------------------------------------------------------------------------
def _color_rotate_core(
rgb_chunk: torch.Tensor, # (B, H, W, 3) in [0,1]
mask_chunk: Optional[torch.Tensor], # (B, H, W, 1) in [0,1] or None
Source_from: float,
Source_to: float,
Source_clockwise: bool,
Destination_from: float,
Destination_to: float,
Destination_clockwise: bool,
Gray_threshold: float,
Mode: str,
Hue: float,
Saturation: float,
) -> torch.Tensor:
"""
Applies GIMP-style color rotate to a chunk of RGB images,
optionally blending with a soft mask.
Returns an RGB tensor of the same shape as `rgb_chunk`.
"""
# RGB → HSV
hue, sat, val = rgb_to_hsv(rgb_chunk)
hue = hue * 360.0
# Gray handling
is_gray = sat <= Gray_threshold
replacement_hue = torch.full_like(hue, Hue % 360.0)
replacement_sat = torch.full_like(sat, float(max(0.0, min(1.0, Saturation))))
if Mode == "Change to this":
rotate_hue = hue
rotate_sat = sat
hue = torch.where(is_gray, replacement_hue, hue)
sat = torch.where(is_gray, replacement_sat, sat)
rotate_mask = ~is_gray
else: # "Treat as this"
rotate_hue = torch.where(is_gray, replacement_hue, hue)
rotate_sat = torch.where(is_gray, replacement_sat, sat)
hue = rotate_hue
sat = rotate_sat
rotate_mask = torch.ones_like(is_gray)
# Source interval
if Source_clockwise:
span = (Source_to - Source_from) % 360.0
dist = _cw_distance(Source_from, rotate_hue)
else:
span = (Source_from - Source_to) % 360.0
dist = _ccw_distance(Source_from, rotate_hue)
if span < 1e-6:
span = 360.0
inside = dist <= span
rotate_mask &= inside
# Normalised position
t = (dist / span).clamp(0.0, 1.0)
# Destination interval
if Destination_clockwise:
dst_span = (Destination_to - Destination_from) % 360.0
new_hue = (Destination_from + t * dst_span) % 360.0
else:
dst_span = (Destination_from - Destination_to) % 360.0
new_hue = (Destination_from - t * dst_span) % 360.0
hue = torch.where(rotate_mask, new_hue, hue)
# HSV → RGB
new_rgb = hsv_to_rgb(hue / 360.0, sat.clamp(0.0, 1.0), val)
# Soft mask blending
if mask_chunk is not None:
rgb_out = rgb_chunk * (1.0 - mask_chunk) + new_rgb * mask_chunk
else:
rgb_out = new_rgb
return rgb_out
# -----------------------------------------------------------------------------
# Main entry point – batch processing, alpha preservation, chunking, early exit
# -----------------------------------------------------------------------------
def exec_operator_color_map_color_rotate(
image: torch.Tensor,
Source_from: float,
Source_to: float,
Source_clockwise: bool,
Destination_from: float,
Destination_to: float,
Destination_clockwise: bool,
Gray_threshold: float,
Mode: str,
Hue: float,
Saturation: float,
mask: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor]:
"""
Apply a GIMP‑style color‑rotate effect to a batch of RGB or RGBA images.
The operation remaps a source hue interval to a destination hue interval.
An optional per‑pixel soft mask controls the blending strength.
Alpha channels (if present) are preserved unchanged.
Processing is performed in chunks along the batch dimension to manage
GPU memory usage. If the optional mask is entirely zero the function
returns the input immediately without any computation.
Parameters
----------
image : torch.Tensor
Input batch of shape (B, H, W, 3) or (B, H, W, 4), values in [0, 1].
Source_from, Source_to : float
Bounds of the source hue interval in degrees [0, 360].
Source_clockwise : bool
Direction of the source interval (clockwise if True).
Destination_from, Destination_to : float
Bounds of the destination hue interval in degrees [0, 360].
Destination_clockwise : bool
Direction of the destination interval.
Gray_threshold : float
Saturation below which a pixel is considered gray [0, 1].
Mode : str
"Change to this" – replace gray pixels with the given Hue/Saturation;
"Treat as this" – treat gray pixels as if they had the given Hue/Saturation.
Hue : float
Replacement/treatment hue for gray pixels [0, 360].
Saturation : float
Replacement/treatment saturation for gray pixels [0, 1].
mask : torch.Tensor, optional
Soft mask of shape (B, H, W) or (B, H, W, 1), values in [0, 1].
When provided, the effect is blended per‑pixel: output = image*(1‑mask) + effect*mask.
Returns
-------
Tuple[torch.Tensor]
A single‑element tuple containing the processed image batch of the same
shape and channel count as the input.
"""
# ----- Validation --------------------------------------------------------
if not isinstance(image, torch.Tensor):
raise TypeError("image must be a torch.Tensor")
if image.dim() != 4:
raise ValueError(
f"image must be 4‑dimensional (B,H,W,C), got shape {image.shape}"
)
if image.shape[-1] not in (3, 4):
raise ValueError(
f"image must have 3 or 4 channels (RGB or RGBA), got {image.shape[-1]}"
)
# ----- Alpha preservation ------------------------------------------------
has_alpha = image.shape[-1] == 4
if has_alpha:
alpha = image[..., 3:4] # keep last dim for concatenation
rgb_image = image[..., :3]
else:
alpha = None
rgb_image = image
# ----- Mask validation ---------------------------------------------------
if mask is not None:
if not isinstance(mask, torch.Tensor):
raise TypeError("mask must be a torch.Tensor")
# Unify shape to (B, H, W, 1)
if mask.ndim == 3:
mask = mask.unsqueeze(-1)
elif mask.ndim != 4 or mask.shape[-1] != 1:
raise ValueError(
f"mask must have shape (B,H,W) or (B,H,W,1), got {mask.shape}"
)
if mask.shape[0] != image.shape[0]:
raise ValueError(
f"mask batch size ({mask.shape[0]}) does not match image ({image.shape[0]})"
)
if mask.shape[1:3] != image.shape[1:3]:
raise ValueError(
f"mask spatial size {mask.shape[1:3]} does not match image {image.shape[1:3]}"
)
# Move to image device/dtype
mask = mask.to(device=rgb_image.device, dtype=rgb_image.dtype)
# ----- Early exit (mask is fully transparent) ----------------------------
if mask is not None and mask.max() == 0:
return (image,)
B = rgb_image.shape[0]
if B == 0:
return (image,)
# ----- Chunked processing ------------------------------------------------
chunk_size = compute_chunk_size(rgb_image, mask)
if chunk_size <= 0 or chunk_size > B:
chunk_size = B
out_chunks = []
for start in range(0, B, chunk_size):
end = min(start + chunk_size, B)
rgb_chunk = rgb_image[start:end]
mask_chunk = mask[start:end] if mask is not None else None
chunk_out = _color_rotate_core(
rgb_chunk=rgb_chunk,
mask_chunk=mask_chunk,
Source_from=Source_from,
Source_to=Source_to,
Source_clockwise=Source_clockwise,
Destination_from=Destination_from,
Destination_to=Destination_to,
Destination_clockwise=Destination_clockwise,
Gray_threshold=Gray_threshold,
Mode=Mode,
Hue=Hue,
Saturation=Saturation,
)
out_chunks.append(chunk_out)
rgb_result = torch.cat(out_chunks, dim=0)
# ----- Recombine alpha ---------------------------------------------------
if has_alpha:
result = torch.cat([rgb_result, alpha], dim=-1)
else:
result = rgb_result
return (result,)
+168
View File
@@ -0,0 +1,168 @@
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,)
+185
View File
@@ -0,0 +1,185 @@
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,)
+220
View File
@@ -0,0 +1,220 @@
import torch
from typing import Tuple
from .libs.blur import gaussian_blur_2d
from .libs.process import compute_chunk_size
def _process_core(
image: torch.Tensor,
Shadows: float,
Shadows_color_adj: float,
Highlights: float,
Highlights_color_adj: float,
White_point_adj: float,
Radius: float,
Compress: float,
mask: torch.Tensor = None,
) -> torch.Tensor:
"""
Apply the GEGL‑style shadows/highlights correction to a single batch
chunk. This function is identical to the original processing logic.
"""
img = image[..., :3].clamp(0.0, 1.0)
device = img.device
# GEGL luminance (Rec.709)
lum = 0.2126 * img[..., 0] + 0.7152 * img[..., 1] + 0.0722 * img[..., 2]
# White point correction
white = 1.0 - (White_point_adj / 100.0)
lum = torch.clamp(lum / max(white, 1e-6), 0.0, 1.0)
# Local context (GEGL radius model)
local = gaussian_blur_2d(lum, Radius)
# Normalise parameters
shadows = max(-1.0, min(1.0, float(Shadows) / 100.0))
highlights = max(-1.0, min(1.0, float(Highlights) / 100.0))
compress = max(0.0, min(0.99, float(Compress) / 100.0))
# GEGL‑like nonlinear weighting
shadow_w = (1.0 - local) ** 1.5
highlight_w = local ** 1.5
lum_out = lum + shadows * shadow_w - highlights * highlight_w
# Compression (midtone preservation)
mid = lum * (1.0 - lum) * 4.0
lum_out = lum_out * (1.0 - compress * mid)
lum_out = torch.clamp(lum_out, 0.0, 1.0)
# Saturation model (correct luminance)
gray = 0.2126 * img[..., 0:1] + 0.7152 * img[..., 1:2] + 0.0722 * img[..., 2:3]
sat_shadow = 1.0 - (1.0 - Shadows_color_adj / 100.0) * (1.0 - local.unsqueeze(-1))
sat_high = 1.0 - (1.0 - Highlights_color_adj / 100.0) * local.unsqueeze(-1)
sat = sat_shadow * sat_high
rgb = gray + (img - gray) * sat
# Luminance correction (safe division)
rgb = rgb * (lum_out.unsqueeze(-1) / (lum.unsqueeze(-1) + 1e-6))
rgb = torch.clamp(rgb, 0.0, 1.0)
# Mask blending
if mask is not None:
mask = mask.to(device)
if mask.dim() == 3:
mask = mask.unsqueeze(-1)
elif mask.dim() == 4 and mask.shape[1] == 1:
mask = mask.permute(0, 2, 3, 1)
mask = mask.expand_as(rgb)
rgb = img * (1.0 - mask) + rgb * mask
# Preserve alpha channel
if image.shape[-1] == 4:
rgb = torch.cat([rgb, image[..., 3:4]], dim=-1)
return rgb
def exec_operator_color_shadows_highlights(
image: torch.Tensor,
Shadows: float = 0.0,
Shadows_color_adj: float = 100.0,
Highlights: float = 0.0,
Highlights_color_adj: float = 50.0,
White_point_adj: float = 0.0,
Radius: float = 100.0,
Compress: float = 50.0,
mask: torch.Tensor = None,
) -> Tuple[torch.Tensor]:
"""
Apply GEGL‑inspired shadows/highlights correction to a batch of images.
This operator adjusts the luminance and colour saturation of shadows and
highlights independently. It preserves the alpha channel when present and
supports optional soft masking.
Parameters
----------
image : torch.Tensor
Input image tensor of shape (B, H, W, C) with C = 3 (RGB) or 4 (RGBA).
Values are expected in the [0, 1] range.
Shadows : float
Amount of shadow brightening. Range [-100, 100] → [-1, 1] internally.
Shadows_color_adj : float
Colour saturation adjustment for shadows, 0 = desaturate, 100 = keep.
Highlights : float
Amount of highlight darkening. Range [-100, 100] → [-1, 1] internally.
Highlights_color_adj : float
Colour saturation adjustment for highlights.
White_point_adj : float
White‑point correction, range [-10, 10].
Radius : float
Spatial radius for the local luminance estimation (pixels).
Compress : float
Midtone compression strength, range [0, 100].
mask : torch.Tensor, optional
Soft mask with shape (B, H, W) or (B, 1, H, W). 1 = full effect,
0 = original image.
Returns
-------
Tuple[torch.Tensor]
A single‑element tuple containing the processed image tensor
with the same shape and number of channels as the input.
"""
# ------------------------------------------------------------
# Early exit: empty input
# ------------------------------------------------------------
if image is None or image.numel() == 0:
return (image,)
# ------------------------------------------------------------
# Early exit: identity operation (no changes)
# ------------------------------------------------------------
if (
Shadows == 0.0
and Highlights == 0.0
and White_point_adj == 0.0
and Compress == 0.0
and Shadows_color_adj == 100.0
and Highlights_color_adj == 100.0
and mask is None
):
return (image,)
# ------------------------------------------------------------
# Input validation
# ------------------------------------------------------------
if not isinstance(image, torch.Tensor):
raise TypeError("image must be a torch.Tensor")
if image.dim() != 4 or image.shape[-1] not in (3, 4):
raise ValueError(
f"image must have shape (B,H,W,C) with C=3 or 4, got {image.shape}"
)
batch_size = image.shape[0]
device = image.device
if mask is not None:
if not isinstance(mask, torch.Tensor):
raise TypeError("mask must be a torch.Tensor")
if mask.shape[0] != batch_size:
raise ValueError(
f"mask batch size {mask.shape[0]} does not match image batch size {batch_size}"
)
if mask.dim() == 4:
if mask.shape[1] != 1 or mask.shape[2:] != image.shape[1:3]:
raise ValueError(
f"mask shape {mask.shape} is incompatible with image shape {image.shape}"
)
elif mask.dim() == 3:
if mask.shape[1:] != image.shape[1:3]:
raise ValueError(
f"mask shape {mask.shape} is incompatible with image shape {image.shape}"
)
else:
raise ValueError("mask must be 3D (B,H,W) or 4D (B,1,H,W)")
# ------------------------------------------------------------
# Chunked processing for GPU VRAM management
# ------------------------------------------------------------
try:
# Compute a safe chunk size (batch count) based on available VRAM
chunk_size = compute_chunk_size(image, mask)
chunk_size = max(1, int(chunk_size)) # ensure valid
output_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_core(
img_chunk,
Shadows,
Shadows_color_adj,
Highlights,
Highlights_color_adj,
White_point_adj,
Radius,
Compress,
mask_chunk,
)
output_chunks.append(processed)
result = torch.cat(output_chunks, dim=0)
except Exception as e:
# On any processing error, return the original image and print a warning
print(f"[Shadows/Highlights] Error: {e}. Returning original image.")
return (image,)
return (result,)
+209
View File
@@ -0,0 +1,209 @@
import math
from typing import Optional
import torch
from .libs.color import srgb_to_linear
# -----------------------------------------------------------------------------
# Temperature limits
# -----------------------------------------------------------------------------
LOWEST_TEMPERATURE = 1000.0
HIGHEST_TEMPERATURE = 40000.0
def kelvin_to_linear_rgb(kelvin: float):
"""
Convert a colour temperature (in Kelvin) to a normalised linear‑RGB
white point using Tanner Helland's black‑body approximation.
The output is a 3‑tuple whose largest component is exactly 1.0.
Values are in linear light (not sRGB).
Parameters
----------
kelvin : float
Colour temperature in Kelvin. Clamped to [1000, 40000].
Returns
-------
tuple of float
Normalised linear‑RGB white point (r, g, b).
"""
T = max(LOWEST_TEMPERATURE, min(HIGHEST_TEMPERATURE, float(kelvin)))
t = T / 100.0
if t <= 66.0:
r = 255.0
g = 99.4708025861 * math.log(t) - 161.1195681661
if t <= 19.0:
b = 0.0
else:
b = 138.5177312231 * math.log(t - 10.0) - 305.0447927307
else:
r = 329.698727446 * ((t - 60.0) ** -0.1332047592)
g = 288.1221695283 * ((t - 60.0) ** -0.0755148492)
b = 255.0
r = min(255.0, max(0.0, r))
g = min(255.0, max(0.0, g))
b = min(255.0, max(0.0, b))
rgb = [
srgb_to_linear(r / 255.0),
srgb_to_linear(g / 255.0),
srgb_to_linear(b / 255.0),
]
m = max(rgb)
if m > 0:
rgb = [x / m for x in rgb]
return tuple(rgb)
# -----------------------------------------------------------------------------
# Colour‑temperature adjustment
# -----------------------------------------------------------------------------
@torch.no_grad()
def exec_operator_color_temperature(
image: torch.Tensor,
original_temp: float,
intended_temp: float,
mask: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""
Adjust the white balance of an RGB or RGBA image.
The image is assumed to be in a **linear** colour space. The
adjustment consists of mapping the white point from
``original_temp`` Kelvin to ``intended_temp`` Kelvin, preserving
approximate luminance. An optional mask allows a per‑pixel blend
between the original and the corrected image.
Parameters
----------
image : torch.Tensor
Linear‑RGB or linear‑RGBA image tensor.
Allowed shapes:
``(H, W, C)`` with ``C = 3`` or ``C = 4``
``(B, H, W, C)`` with ``C = 3`` or ``C = 4``
Dtype must be a floating‑point type (e.g. float32, float16).
original_temp : float
Colour temperature of the image's current white point (Kelvin).
intended_temp : float
Desired colour temperature for the white point (Kelvin).
mask : torch.Tensor, optional
Blend mask. Values are clamped to [0, 1] and used to linearly
interpolate between the original image (mask=0) and the
temperature‑adjusted image (mask=1).
Accepted shapes (the number of spatial dimensions must match
the image, and the batch dimension, if present, must be 1 or
equal to the image batch size):
``(H, W)``
``(H, W, 1)``
``(H, W, C)`` (averaged to a single channel)
``(B, H, W)``
``(B, H, W, 1)``
``(B, H, W, C)``
If the last dimension is not ``1``, the mask is reduced by
averaging its RGB channels (the alpha channel, if present, is
ignored). For a single‑channel mask no averaging takes place.
Returns
-------
torch.Tensor
Temperature‑adjusted image, with the same shape, dtype and
device as ``image``. Alpha channel (if present) is passed
through unchanged.
Raises
------
ValueError
If the image does not have 3 or 4 channels, or if the mask
shape is incompatible, or if tensors reside on different
devices.
TypeError
If ``image`` is not a floating‑point tensor.
"""
# ------------------------------------------------------------------
# Validation
# ------------------------------------------------------------------
if image.dtype not in (torch.float16, torch.float32, torch.float64):
raise TypeError("Image must be a floating‑point tensor.")
if image.ndim not in (3, 4):
raise ValueError(f"Image must have 3 or 4 dimensions, got {image.ndim}.")
if image.shape[-1] not in (3, 4):
raise ValueError(f"Expected 3 or 4 channels, got {image.shape[-1]}.")
if mask is not None:
if mask.device != image.device:
raise ValueError("Mask must be on the same device as the image.")
if mask.ndim not in (image.ndim, image.ndim - 1):
raise ValueError(
f"Mask must have {image.ndim - 1} or {image.ndim} dimensions, "
f"got {mask.ndim}."
)
# Check batch dimension compatibility
if image.ndim == 4 and mask.ndim == 4:
if mask.shape[0] not in (1, image.shape[0]):
raise ValueError(
f"Mask batch size must be 1 or {image.shape[0]}, "
f"got {mask.shape[0]}."
)
# ------------------------------------------------------------------
# White‑point gains
# ------------------------------------------------------------------
src = torch.tensor(
kelvin_to_linear_rgb(original_temp),
dtype=image.dtype,
device=image.device,
)
dst = torch.tensor(
kelvin_to_linear_rgb(intended_temp),
dtype=image.dtype,
device=image.device,
)
eps = torch.finfo(image.dtype).eps
gains = dst / (src + eps)
# preserve approximate luminance
gains = gains / gains.mean()
# Broadcast gains to the image shape
shape = [1] * image.ndim
shape[-1] = 3 if image.shape[-1] == 4 else image.shape[-1]
gains = gains.view(*shape)
# Apply to RGB channels; alpha (if present) is left untouched
if image.shape[-1] == 4:
rgb_result = image[..., :3] * gains
result = torch.cat([rgb_result, image[..., 3:4]], dim=-1)
else:
result = image * gains
# ------------------------------------------------------------------
# Blend with mask
# ------------------------------------------------------------------
if mask is not None:
# Make mask broadcastable with image
if mask.ndim == image.ndim - 1:
mask = mask.unsqueeze(-1)
# If multi‑channel, reduce to a single luminance channel
if mask.shape[-1] != 1:
# Average only the colour channels (ignore alpha if present)
mask = mask[..., :3].mean(dim=-1, keepdim=True)
mask = mask.clamp(0.0, 1.0)
# Linear interpolation: mask=0 → original, mask=1 → adjusted
result = torch.lerp(image, result, mask)
# Ensure valid range (clamping happens in‑place for memory efficiency)
return result.clamp_(0.0, 1.0)
+163
View File
@@ -0,0 +1,163 @@
import torch
from typing import Tuple, Optional
from .libs.process import compute_chunk_size
def exec_operator_color_threshold(
image: torch.Tensor,
channel: str,
low: float,
high: float,
mask: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor]:
"""
Apply a threshold to the input image, producing a binary output.
Supported channels:
Value, Red, Green, Blue, Alpha, Luminance, RGB
Image:
[B,H,W,C] float32 RGB/RGBA in [0,1]
Mask:
[H,W] or [B,H,W]
"""
try:
if not isinstance(image, torch.Tensor):
raise TypeError("image must be a torch.Tensor")
if image.ndim != 4:
raise ValueError("image must be [B,H,W,C]")
B, H, W, C = image.shape
if C not in (3, 4):
raise ValueError("image must be RGB or RGBA")
low = max(0.0, min(255.0, float(low))) / 255.0
high = max(0.0, min(255.0, float(high))) / 255.0
if low > high:
low, high = high, low
if mask is not None:
if not isinstance(mask, torch.Tensor):
raise TypeError("mask must be a torch.Tensor")
if mask.ndim == 2:
mask = mask.unsqueeze(0)
elif mask.ndim != 3:
raise ValueError("mask must be [H,W] or [B,H,W]")
if mask.shape[-2:] != (H, W):
raise ValueError("mask size mismatch")
if mask.shape[0] not in (1, B):
raise ValueError("mask batch mismatch")
mask = mask.to(dtype=image.dtype, device=image.device).clamp_(0, 1)
if torch.count_nonzero(mask) == 0:
return (image,)
if torch.all(mask == 1):
mask = None
device = image.device
chunk_size = (
max(1, compute_chunk_size(image, mask))
if device.type == "cuda"
else B
)
def threshold_chunk(
img: torch.Tensor,
msk: Optional[torch.Tensor],
) -> torch.Tensor:
rgb = img[..., :3]
if channel == "RGB":
inside = (
(rgb[..., 0] >= low)
& (rgb[..., 0] <= high)
& (rgb[..., 1] >= low)
& (rgb[..., 1] <= high)
& (rgb[..., 2] >= low)
& (rgb[..., 2] <= high)
)
else:
if channel == "Value":
intensity = rgb.amax(dim=-1)
elif channel == "Red":
intensity = rgb[..., 0]
elif channel == "Green":
intensity = rgb[..., 1]
elif channel == "Blue":
intensity = rgb[..., 2]
elif channel == "Alpha":
if C == 4:
intensity = img[..., 3]
else:
intensity = torch.ones(
img.shape[:-1],
device=img.device,
dtype=img.dtype,
)
elif channel == "Luminance":
intensity = (
rgb[..., 0] * 0.299
+ rgb[..., 1] * 0.587
+ rgb[..., 2] * 0.114
)
else:
raise ValueError(f"Unknown channel '{channel}'")
inside = (intensity >= low) & (intensity <= high)
bw = inside.to(img.dtype)[..., None]
out_rgb = bw.expand(-1, -1, -1, 3).clone()
if C == 4:
out = torch.cat((out_rgb, img[..., 3:4]), dim=-1)
else:
out = out_rgb
if msk is not None:
out = out * msk[..., None] + img * (1.0 - msk[..., None])
return out
chunks = []
for start in range(0, B, chunk_size):
end = min(start + chunk_size, B)
img_chunk = image[start:end]
if mask is None:
mask_chunk = None
elif mask.shape[0] == 1:
mask_chunk = mask
else:
mask_chunk = mask[start:end]
chunks.append(threshold_chunk(img_chunk, mask_chunk))
return (torch.cat(chunks, dim=0),)
except Exception as e:
raise RuntimeError(
f"Color threshold operation failed: {e}"
) from e
+198
View File
@@ -0,0 +1,198 @@
import torch
import numpy as np
from typing import Tuple
module_cat = "md/image/color"
# -----------------------------------------------------------------------------------
# Core tone mapping implementation (ported from GEGL's fattal02.c / pfstmo)
# -----------------------------------------------------------------------------------
def exec_operator_color_tone_mapping_fattal_et_al_2002(
image: torch.Tensor,
Alpha: float,
Beta: float,
Saturation: float,
Noise: float,
mask: torch.Tensor = None,
) -> Tuple[torch.Tensor]:
"""
Applies Fattal et al. 2002 gradient-domain tone mapping.
Based on GIMP's implementation (GEGL operation 'fattal02').
"""
# Convert to numpy (B, H, W, C) float32 in [0,1]
img_np = image.detach().cpu().numpy().astype(np.float32)
batch_size, H, W, C = img_np.shape
# If mask is provided, use it; otherwise process full image
if mask is not None:
mask_np = mask.detach().cpu().numpy().astype(np.float32)
# mask is (B, H, W) or (B, 1, H, W) – ensure it's (B, H, W, 1)
if mask_np.ndim == 3:
mask_np = mask_np[..., np.newaxis]
elif mask_np.ndim == 4 and mask_np.shape[1] == 1:
mask_np = np.transpose(mask_np, (0, 2, 3, 1))
else:
mask_np = np.ones((batch_size, H, W, 1), dtype=np.float32)
out = []
for b in range(batch_size):
img = img_np[b] # (H, W, C)
msk = mask_np[b] # (H, W, 1)
# ---- 1. Convert to linear luminance (ITU-R BT.709) ----
# GIMP uses: L = 0.2126*R + 0.7152*G + 0.0722*B
luminance = 0.2126 * img[..., 0] + 0.7152 * img[..., 1] + 0.0722 * img[..., 2]
luminance = np.clip(luminance, 1e-6, None) # avoid log(0)
# ---- 2. Tone map the luminance channel ----
tonemapped_lum = _fattal_tone_map(luminance, Alpha, Beta, Noise)
# ---- 3. Apply saturation and recombine ----
# Scale RGB channels by the ratio of new/old luminance
ratio = tonemapped_lum / (luminance + 1e-8)
# Saturation: blend between fully desaturated and original ratio
ratio_sat = 1.0 + Saturation * (ratio - 1.0)
result = img * ratio_sat[..., np.newaxis]
result = np.clip(result, 0.0, 1.0)
# Apply mask
result = result * msk + img * (1.0 - msk)
out.append(result)
# Stack batch and convert back to torch
out_np = np.stack(out, axis=0)
return (torch.from_numpy(out_np).to(image.device),)
# -----------------------------------------------------------------------------------
# Internal Fattal tone mapping core (luminance-only)
# -----------------------------------------------------------------------------------
def _fattal_tone_map(lum: np.ndarray, alpha: float, beta: float, noise: float) -> np.ndarray:
"""
Tone maps a single luminance channel using the Fattal et al. 2002 algorithm.
Based on GEGL's fattal02.c and the pfstmo reference implementation.
"""
H, W = lum.shape
# Work in log domain
log_lum = np.log(lum)
# ---- Build Gaussian pyramid ----
pyramid = [log_lum]
while pyramid[-1].shape[0] > 32 and pyramid[-1].shape[1] > 32:
blurred = _gaussian_blur(pyramid[-1], sigma=1.0)
pyramid.append(blurred[::2, ::2])
n_levels = len(pyramid)
# ---- Compute gradient magnitudes at each level ----
grad_mags = []
for level, layer in enumerate(pyramid):
gy, gx = np.gradient(layer)
# Scale gradients by the level's resolution (as in GEGL)
scale = 2 ** level
mag = np.sqrt((gx / scale) ** 2 + (gy / scale) ** 2)
grad_mags.append(mag)
# ---- Attenuate gradients using the Fattal formula ----
# GEGL uses: attenuated = mag * ( (alpha / mag)^beta ) for mag > alpha
# and for mag <= alpha, attenuated = mag * (alpha / mag)^(beta*noise)?
# More precisely (from GEGL):
# if (mag > alpha) mag2 = mag * pow(alpha / mag, beta)
# else mag2 = mag * pow(alpha / mag, beta * noise)
# then clamp to [0, 1] and scale.
attenuated_mags = []
for mag in grad_mags:
# Avoid division by zero
safe_mag = np.maximum(mag, 1e-8)
ratio = alpha / safe_mag
if noise > 0:
# For small gradients, use noise to control attenuation
mask = (mag > alpha)
exp = np.where(mask, beta, beta * noise)
else:
exp = beta
att = mag * np.power(ratio, exp)
# GEGL clamps attenuated gradients to [0,1] and then scales by original mag?
# Actually it computes: phi = att / mag (where att is the attenuated magnitude)
# Then phi = clamp(phi, 0, 1)
# Then new_grad = phi * original_gradient
phi = np.clip(att / safe_mag, 0.0, 1.0)
attenuated_mags.append(phi)
# ---- Reconstruct the image from the modified gradients (solve Poisson equation) ----
# We solve ∇²u = div( G' ) where G' is the attenuated gradient field.
# Use a simple multi-grid / conjugate gradient approach (GEGL uses linbcg).
# For simplicity, we use a fast Poisson solver via FFT (as in pfstmo).
# Since the pyramid has multiple levels, we propagate from coarsest to finest.
reconstructed = _solve_poisson_pyramid(pyramid, attenuated_mags)
# ---- Convert back to linear domain and normalize ----
result = np.exp(reconstructed)
# Normalize to [0, 1] (GIMP scales by max)
max_val = np.max(result)
if max_val > 0:
result = result / max_val
return np.clip(result, 0.0, 1.0)
def _gaussian_blur(arr: np.ndarray, sigma: float = 1.0) -> np.ndarray:
"""Simple Gaussian blur using separable convolution."""
from scipy.ndimage import gaussian_filter
return gaussian_filter(arr, sigma=sigma, mode='reflect')
def _solve_poisson_pyramid(pyramid: list, attenuated_mags: list) -> np.ndarray:
"""
Reconstructs the log-luminance image from the attenuated gradient magnitudes.
Uses a coarse-to-fine propagation with FFT-based Poisson solver at each level.
"""
# Start from the coarsest level
n_levels = len(pyramid)
# Coarsest level: just use the original log-lum (or solve directly)
# For simplicity, we use the coarsest level as-is (no gradient modification)
# In GEGL, they solve a linear system at each level and upsample.
# Here we implement a simplified version: solve at the finest level using FFT.
# (This is acceptable for a Python port and matches the pfstmo approach.)
# Get the finest level gradients
# We need the original gradients and the attenuation factors
log_lum = pyramid[0]
gy, gx = np.gradient(log_lum)
# Attenuate gradients
phi = attenuated_mags[0] # shape (H, W)
# Apply phi to gradients
gx_att = gx * phi
gy_att = gy * phi
# Solve Poisson equation: ∇²u = div(g_att)
# Using FFT-based solver (periodic boundary conditions)
from scipy.fft import fft2, ifft2, fftfreq
H, W = log_lum.shape
# Compute divergence of attenuated gradient
div_g = np.zeros_like(log_lum)
# Divergence: d(gx)/dx + d(gy)/dy
div_g[1:-1, :] = (gx_att[2:, :] - gx_att[:-2, :]) / 2.0
div_g[:, 1:-1] += (gy_att[:, 2:] - gy_att[:, :-2]) / 2.0
# Handle boundaries (simple forward/backward differences)
div_g[0, :] = gx_att[1, :] - gx_att[0, :]
div_g[-1, :] = gx_att[-1, :] - gx_att[-2, :]
div_g[:, 0] = gy_att[:, 1] - gy_att[:, 0]
div_g[:, -1] = gy_att[:, -1] - gy_att[:, -2]
# Solve using FFT
fx = fftfreq(W, 1.0)
fy = fftfreq(H, 1.0)
FX, FY = np.meshgrid(fx, fy)
denom = -4.0 * (np.pi**2) * (FX**2 + FY**2)
denom[0, 0] = 1.0 # avoid division by zero
F_div = fft2(div_g)
F_u = F_div / denom
F_u[0, 0] = 0.0 # set DC component to zero
u = np.real(ifft2(F_u))
# Add back the mean of the original log-lum to preserve brightness
u = u - np.mean(u) + np.mean(log_lum)
return u
+209
View File
@@ -0,0 +1,209 @@
import torch
# --------------------------------------------------------------------
# Blend mode helpers – kept as module-level functions for clean reuse
# --------------------------------------------------------------------
def blend_normal(bg_rgb, fg_rgb):
return fg_rgb
def blend_screen(bg_rgb, fg_rgb):
return 1.0 - (1.0 - bg_rgb) * (1.0 - fg_rgb)
def blend_multiply(bg_rgb, fg_rgb):
return bg_rgb * fg_rgb
def blend_overlay(bg_rgb, fg_rgb):
result = torch.where(bg_rgb <= 0.5, 2.0 * bg_rgb * fg_rgb,
1.0 - 2.0 * (1.0 - bg_rgb) * (1.0 - fg_rgb))
return result.clamp(0.0, 1.0)
def blend_difference(bg_rgb, fg_rgb):
return torch.abs(bg_rgb - fg_rgb)
def blend_dissolve(bg_rgb, fg_rgb):
noise = torch.rand_like(bg_rgb)
return torch.where(noise < 0.5, fg_rgb, bg_rgb)
def blend_clear(bg_rgb, fg_rgb):
return bg_rgb
def blend_add(bg_rgb, fg_rgb):
return torch.clamp(bg_rgb + fg_rgb, 0.0, 1.0)
def blend_subtract(bg_rgb, fg_rgb):
return torch.clamp(bg_rgb - fg_rgb, 0.0, 1.0)
def blend_replace(bg_rgb, fg_rgb):
return fg_rgb
def blend_darken_only(bg_rgb, fg_rgb):
return torch.min(bg_rgb, fg_rgb)
def blend_lighten_only(bg_rgb, fg_rgb):
return torch.max(bg_rgb, fg_rgb)
def blend_color_dodge(bg_rgb, fg_rgb):
result = bg_rgb / (1.0 - fg_rgb + 1e-8)
return result.clamp(0.0, 1.0)
def blend_color_burn(bg_rgb, fg_rgb):
result = 1.0 - (1.0 - bg_rgb) / (fg_rgb + 1e-8)
return result.clamp(0.0, 1.0)
def blend_hard_light(bg_rgb, fg_rgb):
result = torch.where(fg_rgb <= 0.5, 2.0 * bg_rgb * fg_rgb,
1.0 - 2.0 * (1.0 - bg_rgb) * (1.0 - fg_rgb))
return result.clamp(0.0, 1.0)
def blend_soft_light(bg_rgb, fg_rgb):
result = torch.where(fg_rgb <= 0.5,
(2.0 * bg_rgb * fg_rgb) + (bg_rgb * bg_rgb * (1.0 - 2.0 * fg_rgb)),
2.0 * bg_rgb * (1.0 - fg_rgb) + torch.sqrt(bg_rgb) * (2.0 * fg_rgb - 1.0))
return result.clamp(0.0, 1.0)
def blend_pin_light(bg_rgb, fg_rgb):
result = torch.where(fg_rgb <= 0.5,
torch.min(bg_rgb, 2.0 * fg_rgb),
torch.max(bg_rgb, 2.0 * fg_rgb - 1.0))
return result.clamp(0.0, 1.0)
def blend_linear_dodge_add(bg_rgb, fg_rgb):
return torch.clamp(bg_rgb + fg_rgb, 0.0, 1.0)
def blend_linear_burn_multiply(bg_rgb, fg_rgb):
return torch.clamp(bg_rgb + fg_rgb - 1.0, 0.0, 1.0)
def blend_vivid_light(bg_rgb, fg_rgb):
result = torch.where(fg_rgb <= 0.5,
1.0 - (1.0 - bg_rgb) / (2.0 * fg_rgb + 1e-8),
bg_rgb / (2.0 * (1.0 - fg_rgb) + 1e-8))
return result.clamp(0.0, 1.0)
def blend_halo(bg_rgb, fg_rgb):
edge = torch.abs(bg_rgb - fg_rgb)
glow = (bg_rgb + fg_rgb) * edge
return glow.clamp(0.0, 1.0)
def blend_negate(bg_rgb, fg_rgb):
return 1.0 - torch.abs(bg_rgb - fg_rgb)
def blend_merge_rgb(bg_rgb, fg_rgb):
return fg_rgb
def blend_merge_cyan(bg_rgb, fg_rgb):
cyan = 1.0 - fg_rgb[..., 0:1]
return cyan.repeat(1, 1, 1, 3)
def blend_merge_magenta(bg_rgb, fg_rgb):
magenta = 1.0 - fg_rgb[..., 1:2]
return magenta.repeat(1, 1, 1, 3)
def blend_merge_yellow(bg_rgb, fg_rgb):
yellow = 1.0 - fg_rgb[..., 2:3]
return yellow.repeat(1, 1, 1, 3)
def blend_merge_black(bg_rgb, fg_rgb):
intensity = fg_rgb.mean(dim=-1, keepdim=True)
return intensity.repeat(1, 1, 1, 3)
def blend_fill_fg_color(bg_rgb, fg_rgb):
return fg_rgb
def blend_fill_bg_color(bg_rgb, fg_rgb):
return bg_rgb
def blend_average(bg_rgb, fg_rgb):
return (bg_rgb + fg_rgb) * 0.5
def blend_composite(bg_rgb, fg_rgb):
return fg_rgb
def blend_behind(bg_rgb, fg_rgb):
return fg_rgb
def blend_template(bg_rgb, fg_rgb):
return fg_rgb
def composite_layer_stack(background, layer, blending_mode, transparency, mask=None):
"""
background, layer: tensors of shape [B, H, W, C] where C=3 or 4
mask: optional tensor [B, H, W] or [H, W]; values in [0,1]
transparency: global opacity multiplier
"""
device = background.device
layer = layer.to(device)
bg_h, bg_w = background.shape[1:3]
# ---------- 1. Ensure background and layer have 4 channels ----------
bg = background.clone()
if bg.shape[-1] == 3:
alpha_bg = torch.ones((bg.shape[0], bg_h, bg_w, 1), device=device)
bg = torch.cat([bg, alpha_bg], dim=-1)
else:
alpha_bg = bg[..., 3:4]
fg = layer.clone()
if fg.shape[-1] == 3:
alpha_fg = torch.ones((fg.shape[0], fg.shape[1], fg.shape[2], 1), device=device)
fg = torch.cat([fg, alpha_fg], dim=-1)
else:
alpha_fg = fg[..., 3:4]
bg_rgb = bg[..., :3]
fg_rgb = fg[..., :3]
# ---------- 2. Resize layer to match background dimensions ----------
if fg.shape[1:3] != (bg_h, bg_w):
fg_rgb = torch.nn.functional.interpolate(
fg_rgb.permute(0, 3, 1, 2), size=(bg_h, bg_w), mode='bilinear', align_corners=False
).permute(0, 2, 3, 1)
alpha_fg = torch.nn.functional.interpolate(
alpha_fg.permute(0, 3, 1, 2), size=(bg_h, bg_w), mode='bilinear', align_corners=False
).permute(0, 2, 3, 1)
# ---------- 3. Prepare mask ----------
if mask is None:
mask = torch.ones((bg.shape[0], bg_h, bg_w, 1), device=device)
else:
mask = mask.to(device)
if mask.dim() == 2: # (H, W)
mask = mask.unsqueeze(0).unsqueeze(-1)
elif mask.dim() == 3: # (B, H, W) or (B, H, W, 1)
if mask.shape[-1] != 1:
mask = mask.unsqueeze(-1)
if mask.shape[1:3] != (bg_h, bg_w):
mask = torch.nn.functional.interpolate(
mask.permute(0, 3, 1, 2), size=(bg_h, bg_w), mode='bilinear', align_corners=False
).permute(0, 2, 3, 1)
if mask.shape[0] < bg.shape[0]:
mask = mask.expand(bg.shape[0], -1, -1, -1)
# ---------- 4. Compute final opacity factor ----------
opacity = alpha_fg * mask * transparency # [B, H, W, 1]
# Special handling for "Clear" and "Behind"
if blending_mode.lower() == "clear":
opacity = torch.zeros_like(opacity)
elif blending_mode.lower() == "behind":
opacity = opacity * (1.0 - alpha_bg)
# ---------- 5. Apply RGB blend mode ----------
# Build a mapping from the existing module-level blend functions
blend_func_name = blending_mode.lower().replace(' ', '_').replace('(', '').replace(')', '').replace('-', '_')
# Use the functions defined at module level (they are in the same scope)
blend_func = globals().get(f"blend_{blend_func_name}", blend_normal)
blended_rgb = blend_func(bg_rgb, fg_rgb)
# ---------- 6. Composite RGB using opacity ----------
result_rgb = bg_rgb * (1.0 - opacity) + blended_rgb * opacity
# ---------- 7. Compute result alpha (standard over compositing) ----------
result_alpha = alpha_bg + (1.0 - alpha_bg) * opacity
# ---------- 8. Concatenate and clamp ----------
result = torch.cat([result_rgb, result_alpha], dim=-1).clamp(0.0, 1.0)
return (result,)
View File
+33
View File
@@ -0,0 +1,33 @@
import torch
import torch.nn.functional as F
def gaussian_blur_2d(x, radius: float):
if radius <= 0:
return x
sigma = radius * 0.5
k = int(max(3, radius * 2 + 1))
if k % 2 == 0:
k += 1
device = x.device
coords = torch.arange(k, device=device) - k // 2
g = torch.exp(-(coords ** 2) / (2 * sigma ** 2))
g = g / g.sum()
# reshape for separable conv
g_h = g.view(1, 1, 1, k)
g_v = g.view(1, 1, k, 1)
x = x.unsqueeze(1) # [B,1,H,W]
# IMPORTANT: symmetric SAME padding
pad = k // 2
x = F.conv2d(x, g_h, padding=(0, pad))
x = F.conv2d(x, g_v, padding=(pad, 0))
return x.squeeze(1)
+439
View File
@@ -0,0 +1,439 @@
import torch
import math
#===================================================================================
def srgb_to_linear(c: float) -> float:
if c <= 0.04045: return c / 12.92
return ((c + 0.055) / 1.055) ** 2.4
#===================================================================================
def srgb_to_linear_vec(x: torch.Tensor) -> torch.Tensor:
"""Vectorised sRGB to linear (matches standard IEC 61966‑2‑1)."""
mask = x <= 0.04045
out = torch.zeros_like(x)
out[mask] = x[mask] / 12.92
out[~mask] = ((x[~mask] + 0.055) / 1.055) ** 2.4
return out
#===================================================================================
def linear_to_srgb_vec(x: torch.Tensor) -> torch.Tensor:
"""Vectorised linear to sRGB (inverse of above)."""
mask = x <= 0.0031308
out = torch.zeros_like(x)
out[mask] = x[mask] * 12.92
out[~mask] = 1.055 * (x[~mask] ** (1.0/2.4)) - 0.055
return torch.clamp(out, 0.0, 1.0)
#===================================================================================
def rgb_to_lch_torch(image: torch.Tensor) -> torch.Tensor:
"""
Convert RGB [0,1] to CIE LCH(ab) using D65 white point.
Uses srgb_to_linear_vec (or your own) for gamma handling.
"""
# 1. sRGB → linear RGB
rgb_lin = srgb_to_linear_vec(image)
# 2. linear RGB → XYZ (D65)
M_rgb2xyz = torch.tensor([
[0.4124, 0.3576, 0.1805],
[0.2126, 0.7152, 0.0722],
[0.0193, 0.1192, 0.9505]
], device=image.device, dtype=image.dtype)
xyz = torch.einsum('ij,...j->...i', M_rgb2xyz, rgb_lin)
# 3. XYZ → CIE L*a*b* (D65 white point)
white = torch.tensor([0.95047, 1.0, 0.95883], device=image.device, dtype=image.dtype)
xyz_n = xyz / white
delta = 6.0 / 29.0
delta2 = delta * delta
delta3 = delta * delta * delta
f = torch.zeros_like(xyz_n)
mask_f = xyz_n > delta3
f[mask_f] = xyz_n[mask_f] ** (1.0/3.0)
f[~mask_f] = (xyz_n[~mask_f] / (3.0 * delta2)) + (4.0 / 29.0)
L = 116.0 * f[..., 1] - 16.0
a = 500.0 * (f[..., 0] - f[..., 1])
b = 200.0 * (f[..., 1] - f[..., 2])
# 4. Lab → LCH
C = torch.sqrt(a*a + b*b)
H = torch.atan2(b, a) * (180.0 / math.pi)
H = H % 360.0
H = torch.where(H >= 180.0, H - 360.0, H) # map to [-180, 180)
return torch.stack([L, C, H], dim=-1)
#===================================================================================
def lch_to_rgb_torch(lch: torch.Tensor) -> torch.Tensor:
"""
Convert CIE LCH(ab) back to RGB [0,1].
Uses linear_to_srgb_vec for final gamma compression.
"""
L, C, H = lch[..., 0], lch[..., 1], lch[..., 2]
H_rad = (H % 360.0) * (math.pi / 180.0)
# LCH → Lab
a = C * torch.cos(H_rad)
b = C * torch.sin(H_rad)
# Lab → XYZ
delta = 6.0 / 29.0
delta2 = delta * delta
delta3 = delta * delta * delta
fy = (L + 16.0) / 116.0
fx = fy + a / 500.0
fz = fy - b / 200.0
xyz = torch.zeros_like(lch)
# Inverse f(t)
mask = fx > delta
xyz[..., 0][mask] = fx[mask] ** 3
xyz[..., 0][~mask] = 3.0 * delta2 * (fx[~mask] - 4.0/29.0)
mask = fy > delta
xyz[..., 1][mask] = fy[mask] ** 3
xyz[..., 1][~mask] = 3.0 * delta2 * (fy[~mask] - 4.0/29.0)
mask = fz > delta
xyz[..., 2][mask] = fz[mask] ** 3
xyz[..., 2][~mask] = 3.0 * delta2 * (fz[~mask] - 4.0/29.0)
white = torch.tensor([0.95047, 1.0, 0.95883], device=lch.device, dtype=lch.dtype)
xyz = xyz * white
# XYZ → linear RGB
M_xyz2rgb = torch.tensor([
[ 3.2406, -1.5372, -0.4986],
[-0.9689, 1.8758, 0.0415],
[ 0.0557, -0.2040, 1.0570]
], device=lch.device, dtype=lch.dtype)
rgb_lin = torch.einsum('ij,...j->...i', M_xyz2rgb, xyz)
# linear → sRGB
rgb_srgb = linear_to_srgb_vec(rgb_lin)
return rgb_srgb
#===================================================================================
def hsl_to_rgb(h, s, l):
hp = h / 60.0
c = (1.0 - torch.abs(2.0 * l - 1.0)) * s
x = c * (1.0 - torch.abs(hp % 2.0 - 1.0))
m = l - 0.5 * c
r = torch.zeros_like(l)
g = torch.zeros_like(l)
b = torch.zeros_like(l)
m0 = (hp >= 0) & (hp < 1)
m1 = (hp >= 1) & (hp < 2)
m2 = (hp >= 2) & (hp < 3)
m3 = (hp >= 3) & (hp < 4)
m4 = (hp >= 4) & (hp < 5)
m5 = (hp >= 5) & (hp < 6)
r[m0], g[m0], b[m0] = c[m0], x[m0], 0
r[m1], g[m1], b[m1] = x[m1], c[m1], 0
r[m2], g[m2], b[m2] = 0, c[m2], x[m2]
r[m3], g[m3], b[m3] = 0, x[m3], c[m3]
r[m4], g[m4], b[m4] = x[m4], 0, c[m4]
r[m5], g[m5], b[m5] = c[m5], 0, x[m5]
return torch.clamp(torch.stack([r + m, g + m, b + m], dim=-1), 0.0, 1.0)
#===================================================================================
def rgb_to_hsl(rgb: torch.Tensor):
maxc = rgb.max(dim=-1).values
minc = rgb.min(dim=-1).values
l = (maxc + minc) * 0.5
delta = maxc - minc
s = torch.zeros_like(l)
nz = delta > 1e-8
s[nz] = delta[nz] / (1.0 - torch.abs(2.0 * l[nz] - 1.0) + 1e-8)
r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
h = torch.zeros_like(l)
rmax = (maxc == r) & nz
gmax = (maxc == g) & nz
bmax = (maxc == b) & nz
h_r = ((g - b) / (delta + 1e-8)) % 6.0
h_g = ((b - r) / (delta + 1e-8)) + 2.0
h_b = ((r - g) / (delta + 1e-8)) + 4.0
h = torch.where(rmax, h_r, h)
h = torch.where(gmax, h_g, h)
h = torch.where(bmax, h_b, h)
h = (h * 60.0) % 360.0
return h, s, l
#===================================================================================
def rgb_to_hsv(rgb: torch.Tensor):
"""Convert RGB [0,1] to HSV. Returns (hue, saturation, value)."""
r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
max_val, max_idx = torch.max(rgb, dim=-1)
min_val, _ = torch.min(rgb, dim=-1)
diff = max_val - min_val
# Hue
hue = torch.zeros_like(max_val)
mask = diff != 0
h = torch.zeros_like(max_val)
h = torch.where((max_val == r) & mask, ((g - b) / diff) % 6, h)
h = torch.where((max_val == g) & mask, ((b - r) / diff) + 2, h)
h = torch.where((max_val == b) & mask, ((r - g) / diff) + 4, h)
hue = (h / 6) % 1.0
# Saturation
sat = torch.zeros_like(max_val)
sat = torch.where(max_val != 0, diff / max_val, sat)
# Value
val = max_val
return hue, sat, val
#===================================================================================
def hsv_to_rgb(hue: torch.Tensor, sat: torch.Tensor, val: torch.Tensor):
"""Convert HSV to RGB [0,1]."""
h = hue * 6.0
i = torch.floor(h).long()
f = h - i.float()
p = val * (1 - sat)
q = val * (1 - sat * f)
t = val * (1 - sat * (1 - f))
# Initialize output
rgb = torch.zeros(*hue.shape, 3, device=hue.device)
# Vectorized assignment using advanced indexing
# This is simplified - in practice you'd use torch.where for each case
mask0 = i == 0
mask1 = i == 1
mask2 = i == 2
mask3 = i == 3
mask4 = i == 4
mask5 = i == 5
rgb[..., 0] = torch.where(mask0, val, rgb[..., 0])
rgb[..., 1] = torch.where(mask0, t, rgb[..., 1])
rgb[..., 2] = torch.where(mask0, p, rgb[..., 2])
rgb[..., 0] = torch.where(mask1, q, rgb[..., 0])
rgb[..., 1] = torch.where(mask1, val, rgb[..., 1])
rgb[..., 2] = torch.where(mask1, p, rgb[..., 2])
rgb[..., 0] = torch.where(mask2, p, rgb[..., 0])
rgb[..., 1] = torch.where(mask2, val, rgb[..., 1])
rgb[..., 2] = torch.where(mask2, t, rgb[..., 2])
rgb[..., 0] = torch.where(mask3, p, rgb[..., 0])
rgb[..., 1] = torch.where(mask3, q, rgb[..., 1])
rgb[..., 2] = torch.where(mask3, val, rgb[..., 2])
rgb[..., 0] = torch.where(mask4, t, rgb[..., 0])
rgb[..., 1] = torch.where(mask4, p, rgb[..., 1])
rgb[..., 2] = torch.where(mask4, val, rgb[..., 2])
rgb[..., 0] = torch.where(mask5, val, rgb[..., 0])
rgb[..., 1] = torch.where(mask5, p, rgb[..., 1])
rgb[..., 2] = torch.where(mask5, q, rgb[..., 2])
return rgb
#===================================================================================
def rgb_to_lch(rgb: torch.Tensor):
"""Convert RGB to Lch (CIE L*a*b* with chroma)."""
# Convert RGB to XYZ (D65)
rgb_linear = rgb.clone()
mask = rgb_linear <= 0.04045
rgb_linear[mask] = rgb_linear[mask] / 12.92
rgb_linear[~mask] = ((rgb_linear[~mask] + 0.055) / 1.055) ** 2.4
# RGB to XYZ matrix
x = 0.4124564 * rgb_linear[..., 0] + 0.3575761 * rgb_linear[..., 1] + 0.1804375 * rgb_linear[..., 2]
y = 0.2126729 * rgb_linear[..., 0] + 0.7151522 * rgb_linear[..., 1] + 0.0721750 * rgb_linear[..., 2]
z = 0.0193339 * rgb_linear[..., 0] + 0.1191920 * rgb_linear[..., 1] + 0.9503041 * rgb_linear[..., 2]
# XYZ to Lab (D65 white point)
xn, yn, zn = 0.95047, 1.0, 1.08883
xr, yr, zr = x / xn, y / yn, z / zn
f = lambda t: torch.where(t > 0.008856, t ** (1/3), 7.787 * t + 16/116)
fx, fy, fz = f(xr), f(yr), f(zr)
L = 116 * fy - 16
a = 500 * (fx - fy)
b = 200 * (fy - fz)
# Chroma and hue
C = torch.sqrt(a**2 + b**2)
H = torch.atan2(b, a) % (2 * torch.pi)
return L, C, H
#===================================================================================
def lch_to_rgb(L: torch.Tensor, C: torch.Tensor, H: torch.Tensor):
"""Convert Lch to RGB."""
# Lch to Lab
a = C * torch.cos(H)
b = C * torch.sin(H)
# Lab to XYZ (D65)
fy = (L + 16) / 116
fx = a / 500 + fy
fz = fy - b / 200
f_inv = lambda t: torch.where(t > 0.206893, t**3, (t - 16/116) / 7.787)
xr, yr, zr = f_inv(fx), f_inv(fy), f_inv(fz)
xn, yn, zn = 0.95047, 1.0, 1.08883
x, y, z = xr * xn, yr * yn, zr * zn
# XYZ to RGB
r = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z
g = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z
b = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z
rgb = torch.stack([r, g, b], dim=-1)
# Linear to sRGB
mask = rgb <= 0.0031308
rgb[mask] = 12.92 * rgb[mask]
rgb[~mask] = 1.055 * rgb[~mask] ** (1/2.4) - 0.055
return torch.clamp(rgb, 0, 1)
#===================================================================================
def rgb_to_yuv(rgb: torch.Tensor):
"""Convert RGB to Yuv (CIE Yuv)."""
# BT.709 coefficients
y = 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2]
u = rgb[..., 0] - y
v = rgb[..., 2] - y
return y, u, v
#===================================================================================
def yuv_to_rgb(y: torch.Tensor, u: torch.Tensor, v: torch.Tensor):
"""Convert Yuv to RGB."""
r = y + u
g = y - 0.21482 * u - 0.38129 * v # Simplified: approximate
b = y + v
rgb = torch.stack([r, g, b], dim=-1)
return torch.clamp(rgb, 0, 1)
#===================================================================================
def rgb_to_lch_torch(image: torch.Tensor) -> torch.Tensor:
"""
Convert RGB [0,1] to CIE LCH(ab) using D65 white point.
Returns tensor with last dimension [L, C, H].
"""
# 1. sRGB → linear RGB
rgb_lin = srgb_to_linear_vec(image)
# 2. linear RGB → XYZ (D65)
M_rgb2xyz = torch.tensor([
[0.4124, 0.3576, 0.1805],
[0.2126, 0.7152, 0.0722],
[0.0193, 0.1192, 0.9505]
], device=image.device, dtype=image.dtype)
xyz = torch.einsum('ij,...j->...i', M_rgb2xyz, rgb_lin)
# 3. XYZ → CIE L*a*b* (D65 white point)
white = torch.tensor([0.95047, 1.0, 0.95883], device=image.device, dtype=image.dtype)
xyz_n = xyz / white
delta = 6.0 / 29.0
delta2 = delta * delta
delta3 = delta * delta * delta
f = torch.zeros_like(xyz_n)
mask_f = xyz_n > delta3
f[mask_f] = xyz_n[mask_f] ** (1.0/3.0)
f[~mask_f] = (xyz_n[~mask_f] / (3.0 * delta2)) + (4.0 / 29.0)
L = 116.0 * f[..., 1] - 16.0
a = 500.0 * (f[..., 0] - f[..., 1])
b = 200.0 * (f[..., 1] - f[..., 2])
# 4. Lab → LCH
C = torch.sqrt(a*a + b*b)
H = torch.atan2(b, a) * (180.0 / math.pi)
H = H % 360.0
H = torch.where(H >= 180.0, H - 360.0, H) # map to [-180, 180)
return torch.stack([L, C, H], dim=-1)
#===================================================================================
def lch_to_rgb_torch(lch: torch.Tensor) -> torch.Tensor:
"""
Convert CIE LCH(ab) back to RGB [0,1].
"""
L, C, H = lch[..., 0], lch[..., 1], lch[..., 2]
H_rad = (H % 360.0) * (math.pi / 180.0)
# LCH → Lab
a = C * torch.cos(H_rad)
b = C * torch.sin(H_rad)
# Lab → XYZ
delta = 6.0 / 29.0
delta2 = delta * delta
delta3 = delta * delta * delta
fy = (L + 16.0) / 116.0
fx = fy + a / 500.0
fz = fy - b / 200.0
xyz = torch.zeros_like(lch)
# Inverse f(t)
mask = fx > delta
xyz[..., 0][mask] = fx[mask] ** 3
xyz[..., 0][~mask] = 3.0 * delta2 * (fx[~mask] - 4.0/29.0)
mask = fy > delta
xyz[..., 1][mask] = fy[mask] ** 3
xyz[..., 1][~mask] = 3.0 * delta2 * (fy[~mask] - 4.0/29.0)
mask = fz > delta
xyz[..., 2][mask] = fz[mask] ** 3
xyz[..., 2][~mask] = 3.0 * delta2 * (fz[~mask] - 4.0/29.0)
white = torch.tensor([0.95047, 1.0, 0.95883], device=lch.device, dtype=lch.dtype)
xyz = xyz * white
# XYZ → linear RGB
M_xyz2rgb = torch.tensor([
[ 3.2406, -1.5372, -0.4986],
[-0.9689, 1.8758, 0.0415],
[ 0.0557, -0.2040, 1.0570]
], device=lch.device, dtype=lch.dtype)
rgb_lin = torch.einsum('ij,...j->...i', M_xyz2rgb, xyz)
# linear → sRGB
rgb_srgb = linear_to_srgb_vec(rgb_lin)
return rgb_srgb
#===================================================================================
def pack_rgb(r, g, b):
"""r,g,b in 0..1, returns (packed_int, '#RRGGBB')"""
r = max(0.0, min(1.0, r))
g = max(0.0, min(1.0, g))
b = max(0.0, min(1.0, b))
ri = int(round(r * 255))
gi = int(round(g * 255))
bi = int(round(b * 255))
packed = (ri << 16) | (gi << 8) | bi
hex_str = f"#{ri:02X}{gi:02X}{bi:02X}"
return packed, hex_str
+76
View File
@@ -0,0 +1,76 @@
import torch
from typing import Optional
def compute_chunk_size(
image: torch.Tensor,
mask: Optional[torch.Tensor] = None,
) -> int:
"""
Determine a safe batch-size chunk for GPU processing based on the
currently available CUDA memory.
The estimation accounts for the memory required by the input image,
an optional mask tensor, the output image, and temporary working
tensors created during color conversion.
Args:
image (torch.Tensor):
Input image tensor of shape (B, H, W, C).
mask (torch.Tensor, optional):
Optional mask tensor associated with the image batch.
Returns:
int:
Maximum number of images that can be processed safely in a
single chunk. If the image resides on the CPU, the complete
batch size is returned.
"""
if image.device.type != "cuda":
return image.shape[0]
free_mem, _ = torch.cuda.mem_get_info(image.device)
image_batch = max(image.shape[0], 1)
# Bytes required by one image sample
image_sample_bytes = (
image[0].numel() * image.element_size()
)
# Bytes required by one mask sample
mask_sample_bytes = 0
if mask is not None:
if mask.device != image.device:
mask = mask.to(image.device)
mask_batch = max(mask.shape[0], 1)
if mask_batch == image_batch:
mask_sample_bytes = mask[0].numel() * mask.element_size()
elif mask_batch == 1:
# Single mask broadcast across the batch
mask_sample_bytes = mask[0].numel() * mask.element_size()
else:
raise ValueError(
f"Mask batch size ({mask_batch}) does not match image batch size ({image_batch})."
)
# Working memory:
# input image
# output image
# temporary color conversion tensors
# optional mask
working_factor = 4
required_per_sample = (
image_sample_bytes * working_factor +
mask_sample_bytes
)
# Keep a safety margin so other CUDA allocations have room.
usable_memory = int(free_mem * 0.90)
return max(1, min(image_batch, usable_memory // max(required_per_sample, 1)))
+6
View File
@@ -0,0 +1,6 @@
[project]
name = "MdColorMod"
version = "1.0.0"
description = "GIMP color mod nodes in comfyUI"
license = { file = "LICENSE" }
dependencies = []
View File
+43
View File
@@ -0,0 +1,43 @@
import { app } from "../../../scripts/app.js";
import { color_balance_widget } from "./nodes/color_balance_widget.js";
import { color_hue_saturation_widget } from "./nodes/color_hue_saturation_widget.js";
//import { addRGBIntegerWidget } from "./widgets/rgb_int_widget.js";
app.registerExtension({
name: "md.color_mod.frontend_extension",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
const onNodeCreated = nodeType.prototype.onNodeCreated;
if (nodeData.name === "mdColorBalance") {
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, arguments);
this._inst = new color_balance_widget(this);
};
}
if (nodeData.name === "mdColorHueSaturation") {
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, arguments);
this._inst = new color_hue_saturation_widget(this);
};
}
/*
if (nodeData.name === "mdPrimitiveColorRGB") {
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
addRGBIntegerWidget(this, "rgb", 16711680);
return result;
};
}
*/
},
});
+9
View File
@@ -0,0 +1,9 @@
export const set_node_widget_value = (node, widget_id, value) => {
let widget = node.widgets?.find(w => w.name === widget_id);
if (!widget) return console.error(`Could not find ${widget_id} widget`);
//console.log(`set ${widget_id} to ${value}`);
widget.value = value;
};
+103
View File
@@ -0,0 +1,103 @@
import { set_node_widget_value } from "../libs/widgets_module.js";
export class color_balance_widget {
constructor(node) {
this.node = node;
this.rangeNames = ["Shadow", "Midtonnes", "Highlights"];
// Find widgets
this.rangeWidget = node.widgets?.find(w => w.name === "range");
this.cyanWidget = node.widgets?.find(w => w.name === "Cyan-Red");
this.magentaWidget = node.widgets?.find(w => w.name === "Magenta-Green");
this.yellowWidget = node.widgets?.find(w => w.name === "Yellow-Blue");
this.dataWidget = node.widgets?.find(w => w.name === "jdata");
if (!this.rangeWidget || !this.cyanWidget || !this.magentaWidget || !this.yellowWidget || !this.dataWidget) {
console.warn("mdColorBalance: some widgets missing");
return;
}
this.dataWidget.hidden = true;
this.setupCallbacks();
// Initial load after the node is fully ready
setTimeout(() => this.loadFromData(), 0);
}
getCurrentRangeIndex() {
return this.rangeNames.indexOf(this.rangeWidget.value);
}
saveCurrentToData() {
let data;
try {
data = JSON.parse(this.dataWidget.value);
} catch {
data = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
}
const idx = this.getCurrentRangeIndex();
if (idx === -1) return;
data[idx] = [
parseFloat(this.cyanWidget.value),
parseFloat(this.magentaWidget.value),
parseFloat(this.yellowWidget.value)
];
this.dataWidget.value = JSON.stringify(data);
if (this.dataWidget.callback) this.dataWidget.callback(this.dataWidget.value);
}
loadFromData() {
let data;
try {
data = JSON.parse(this.dataWidget.value);
} catch {
data = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
}
const idx = this.getCurrentRangeIndex();
if (idx === -1) return;
const vals = data[idx] || [0, 0, 0];
// Use the shared utility to set widget values
set_node_widget_value(this.node, "Cyan-Red", vals[0]);
set_node_widget_value(this.node, "Magenta-Green", vals[1]);
set_node_widget_value(this.node, "Yellow-Blue", vals[2]);
// Notify the widget callbacks so the UI updates
if (this.cyanWidget.callback) this.cyanWidget.callback(this.cyanWidget.value);
if (this.magentaWidget.callback) this.magentaWidget.callback(this.magentaWidget.value);
if (this.yellowWidget.callback) this.yellowWidget.callback(this.yellowWidget.value);
}
setupCallbacks() {
// --- Range dropdown ---
const origRangeCb = this.rangeWidget.callback;
this.rangeWidget.callback = (value) => {
// Save current slider values for the old range
this.saveCurrentToData();
// Load slider values for the new range
this.loadFromData();
// Invoke any existing callback
if (origRangeCb) origRangeCb(value);
};
// --- Slider widgets ---
const onSliderChange = () => {
this.saveCurrentToData();
};
// Preserve existing callbacks by wrapping them
const wrapSliderCallback = (widget, extra) => {
const orig = widget.callback;
widget.callback = function(value) {
extra();
if (orig) orig.call(this, value); // ✅ Preserve `this` context
};
};
wrapSliderCallback(this.cyanWidget, onSliderChange);
wrapSliderCallback(this.magentaWidget, onSliderChange);
wrapSliderCallback(this.yellowWidget, onSliderChange);
}
}
+104
View File
@@ -0,0 +1,104 @@
import { set_node_widget_value } from "../libs/widgets_module.js";
export class color_hue_saturation_widget {
constructor(node) {
this.node = node;
// Order must match the dropdown: Master, Red, Yellow, Green, Cyan, Blue, Magenta
this.rangeNames = ["Master", "Red", "Yellow", "Green", "Cyan", "Blue", "Magenta"];
// Find widgets
this.colorWidget = node.widgets?.find(w => w.name === "color");
this.hueWidget = node.widgets?.find(w => w.name === "Hue");
this.lightnessWidget = node.widgets?.find(w => w.name === "Lightness");
this.saturationWidget = node.widgets?.find(w => w.name === "Saturation");
this.dataWidget = node.widgets?.find(w => w.name === "jdata");
if (!this.colorWidget || !this.hueWidget || !this.lightnessWidget ||
!this.saturationWidget || !this.dataWidget) {
console.warn("mdColorHueSaturation: some widgets missing");
return;
}
this.dataWidget.hidden = true;
this.setupCallbacks();
// Initial load after the node is fully ready
setTimeout(() => this.loadFromData(), 0);
}
getCurrentRangeIndex() {
return this.rangeNames.indexOf(this.colorWidget.value);
}
saveCurrentToData() {
let data;
try {
data = JSON.parse(this.dataWidget.value);
} catch {
data = this.rangeNames.map(() => [0, 0, 0]);
}
const idx = this.getCurrentRangeIndex();
if (idx === -1) return;
data[idx] = [
parseFloat(this.hueWidget.value),
parseFloat(this.lightnessWidget.value),
parseFloat(this.saturationWidget.value)
];
this.dataWidget.value = JSON.stringify(data);
if (this.dataWidget.callback) this.dataWidget.callback(this.dataWidget.value);
}
loadFromData() {
let data;
try {
data = JSON.parse(this.dataWidget.value);
} catch {
data = this.rangeNames.map(() => [0, 0, 0]);
}
const idx = this.getCurrentRangeIndex();
if (idx === -1) return;
const vals = data[idx] || [0, 0, 0];
// Update sliders using the shared utility
set_node_widget_value(this.node, "Hue", vals[0]);
set_node_widget_value(this.node, "Lightness", vals[1]);
set_node_widget_value(this.node, "Saturation", vals[2]);
// Notify the widget callbacks so the UI updates
if (this.hueWidget.callback) this.hueWidget.callback(this.hueWidget.value);
if (this.lightnessWidget.callback) this.lightnessWidget.callback(this.lightnessWidget.value);
if (this.saturationWidget.callback) this.saturationWidget.callback(this.saturationWidget.value);
}
setupCallbacks() {
// --- Color dropdown ---
const origColorCb = this.colorWidget.callback;
this.colorWidget.callback = (value) => {
// Save current slider values for the old range
this.saveCurrentToData();
// Load slider values for the new range
this.loadFromData();
// Invoke any existing callback
if (origColorCb) origColorCb(value);
};
// --- Slider widgets ---
const onSliderChange = () => {
this.saveCurrentToData();
};
const wrapSliderCallback = (widget, extra) => {
const orig = widget.callback;
widget.callback = function(value) {
extra();
if (orig) orig.call(this, value);
};
};
wrapSliderCallback(this.hueWidget, onSliderChange);
wrapSliderCallback(this.lightnessWidget, onSliderChange);
wrapSliderCallback(this.saturationWidget, onSliderChange);
}
}