Files
l33chKing 89e7920119 Add Color Matching node; Laplacian Sharpen & Unsharp Masking effects
- New ColorMatchingNode: two image inputs, one output, LAB/RGB/Histogram
  methods (LAB default). Live in-node preview recomputes in JS on method
  switch, mirroring the Layer Blending preview pattern.
- Filter Ops: add "Laplacian Sharpen" (4-/8-neighbor kernel) and
  "Unsharp Masking" (Gaussian amount/radius/threshold) effects, with
  matching JS live-preview implementations, widget visibility, tooltips.
- Update README (now four nodes) and node registration.
2026-07-03 11:59:29 +03:00

959 lines
40 KiB
Python

import os
import time
import math
from typing import Dict, Any, List, Tuple
import torch
import numpy as np
from .layer_blending import apply_blend, _downscale_for_preview, _save_web_preview
from .channel_ops import rgb_to_hsv, hsv_to_rgb, rgb_to_oklab, oklab_to_rgb, apply_mask_blend
EFFECT_CHOICES = [
"Blur",
"Halftone",
"Color Filter",
"Sharpen",
"Pixelate",
"Posterize",
"Vignette",
"Levels",
"Color Balance",
"Laplacian Sharpen",
"Unsharp Masking",
]
LAPLACIAN_KERNELS = ["3x3 (4-neighbor)", "3x3 (8-neighbor)"]
BLUR_MODES = ["Gaussian", "Average", "Edge Average"]
LEVELS_CHANNELS = ["RGB", "Red", "Green", "Blue"]
PIXELATE_MODES = ["Pixelate", "Mosaic"]
POSTERIZE_MODES = ["RGB", "Luminance"]
POSTERIZE_DITHER_MODES = ["None", "Bayer", "Random", "Floyd-Steinberg", "Atkinson"]
def _resolve_blur_effect_name(blur_mode: str) -> str:
"""Map the new blur_mode dropdown values to the internal blur effect names
used by _apply_blur_effect. Old workflow effect names ("Average Blur" etc.)
pass through unchanged."""
m = (blur_mode or "Gaussian").strip().lower()
if m == "average":
return "average blur"
if m == "edge average":
return "average edge blur"
if m == "gaussian":
return "gaussian blur"
return m
SOURCE_CHOICES = [
"Red", "Green", "Blue",
"Red+Green", "Red+Blue", "Green+Blue",
"Hue", "Saturation", "Value",
"RGB", "HSV", "Oklab",
]
HALFTONE_SHAPES = [
"Random Dots", "Cross Cut", "Dot", "Line Scalloped", "Line",
"Line Centered", "Rhomboid", "Round", "Saddle", "Spot",
"Spot Diamond", "Square Dot",
]
# 8x8 Bayer matrix for ordered dithering. Approximates Floyd-Steinberg-style
# error diffusion in a fully parallel (tensor-friendly) way.
_BAYER_8 = [
[ 0, 32, 8, 40, 2, 34, 10, 42],
[48, 16, 56, 24, 50, 18, 58, 26],
[12, 44, 4, 36, 14, 46, 6, 38],
[60, 28, 52, 20, 62, 30, 54, 22],
[ 3, 35, 11, 43, 1, 33, 9, 41],
[51, 19, 59, 27, 49, 17, 57, 25],
[15, 47, 7, 39, 13, 45, 5, 37],
[63, 31, 55, 23, 61, 29, 53, 21],
]
BLEND_MODE_CHOICES = [
"Normal", "Multiply", "Screen", "Overlay", "Soft Light", "Hard Light",
"Darken", "Lighten", "Color Dodge", "Color Burn",
"Linear Dodge (Add)", "Linear Burn", "Vivid Light", "Linear Light", "Pin Light",
"Difference", "Exclusion", "Add", "Subtract", "Divide", "Hard Mix",
]
# Which RGB channel indices a Source label touches. HSV/Oklab handled separately.
RGB_INDEX_GROUPS: Dict[str, List[int]] = {
"R": [0], "G": [1], "B": [2],
"R+G": [0, 1], "R+B": [0, 2], "G+B": [1, 2],
"RGB": [0, 1, 2],
}
# ---------- blur primitives ----------
def _box_blur(image: torch.Tensor, radius: int) -> torch.Tensor:
r = int(radius)
if r <= 0:
return image
k = 2 * r + 1
x = image.permute(0, 3, 1, 2)
x = torch.nn.functional.pad(x, (r, r, 0, 0), mode="replicate")
x = torch.nn.functional.avg_pool2d(x, kernel_size=(1, k), stride=1)
x = torch.nn.functional.pad(x, (0, 0, r, r), mode="replicate")
x = torch.nn.functional.avg_pool2d(x, kernel_size=(k, 1), stride=1)
return x.permute(0, 2, 3, 1)
def _bresenham_circle_offsets(r: int) -> List[Tuple[int, int]]:
if r <= 0:
return [(0, 0)]
pts = set()
x, y, d = 0, r, 1 - r
while x <= y:
for p in (
(x, y), (-x, y), (x, -y), (-x, -y),
(y, x), (-y, x), (y, -x), (-y, -x),
):
pts.add(p)
x += 1
if d < 0:
d += 2 * x + 1
else:
y -= 1
d += 2 * (x - y) + 1
return list(pts)
def _average_edge_blur(image: torch.Tensor, radius: int) -> torch.Tensor:
r = int(radius)
if r <= 0:
return image
offsets = _bresenham_circle_offsets(r)
n = len(offsets)
if n == 0:
return image
x = image.permute(0, 3, 1, 2).contiguous()
_, _, H, W = x.shape
x_pad = torch.nn.functional.pad(x, (r, r, r, r), mode="replicate")
acc = torch.zeros_like(x)
for (dx, dy) in offsets:
acc.add_(x_pad[..., r + dy:r + dy + H, r + dx:r + dx + W])
acc.mul_(1.0 / float(n))
return acc.permute(0, 2, 3, 1)
def _gaussian_blur(image: torch.Tensor, radius_param: int) -> torch.Tensor:
rp = int(radius_param)
if rp <= 0:
return image
sigma = max(0.5, float(rp) / 3.0)
kr = max(1, int(round(3.0 * sigma)))
x = image.permute(0, 3, 1, 2).contiguous()
_, _, H, W = x.shape
coords = torch.arange(-kr, kr + 1, device=x.device, dtype=x.dtype)
k1d = torch.exp(-(coords * coords) / (2.0 * sigma * sigma))
k1d = k1d / k1d.sum()
weights = k1d.tolist()
x_pad = torch.nn.functional.pad(x, (kr, kr, 0, 0), mode="replicate")
acc = torch.zeros_like(x)
for i in range(2 * kr + 1):
acc.add_(x_pad[..., :, i:i + W], alpha=weights[i])
x_pad = torch.nn.functional.pad(acc, (0, 0, kr, kr), mode="replicate")
acc = torch.zeros_like(x)
for i in range(2 * kr + 1):
acc.add_(x_pad[..., i:i + H, :], alpha=weights[i])
return acc.permute(0, 2, 3, 1)
def _apply_blur_effect(x: torch.Tensor, effect: str, radius: int) -> torch.Tensor:
eff = (effect or "").strip().lower()
if eff == "average blur":
return _box_blur(x, radius)
if eff == "average edge blur":
return _average_edge_blur(x, radius)
if eff == "gaussian blur":
return _gaussian_blur(x, radius)
return x
def _blur_hue(h: torch.Tensor, effect: str, radius: int) -> torch.Tensor:
two_pi = 2.0 * math.pi
cs = torch.stack([torch.cos(h * two_pi), torch.sin(h * two_pi)], dim=-1)
cs_b = _apply_blur_effect(cs, effect, radius)
h_new = torch.atan2(cs_b[..., 1], cs_b[..., 0]) / two_pi
return torch.remainder(h_new, 1.0)
def _blur_rgb_indices(orig: torch.Tensor, indices: List[int], effect: str, radius: int) -> torch.Tensor:
"""Blur the selected RGB channels (e.g. [0,2] for R+B), keep others original."""
if indices == [0, 1, 2]:
return _apply_blur_effect(orig, effect, radius)
# Stack selected channels into a contiguous sub-tensor so the effect runs in one pass,
# then scatter back into the cloned original.
sub = torch.stack([orig[..., i] for i in indices], dim=-1)
sub_b = _apply_blur_effect(sub, effect, radius)
processed = orig.clone()
for j, i in enumerate(indices):
processed[..., i] = sub_b[..., j]
return processed
# ---------- halftone ----------
def _apply_halftone(
image: torch.Tensor,
shape: str,
inverse: bool,
size: int,
angle_deg: float,
contrast: int,
brightness: int,
quality: int,
dither: int = 100,
) -> torch.Tensor:
device = image.device
dtype = image.dtype
B, H, W, _C = image.shape
sz = max(1, int(size))
# Brightness/contrast on the input. brightness/contrast in [-100,100].
b_off = float(brightness) / 100.0
c_fac = (float(contrast) + 100.0) / 100.0 # 0..2
adj = torch.clamp((image - 0.5) * c_fac + 0.5 + b_off, 0.0, 1.0)
# Per-pixel luminance (Rec.601). This is the "intensity to be reproduced"
# in a classic halftone screen.
lum = 0.299 * adj[..., 0] + 0.587 * adj[..., 1] + 0.114 * adj[..., 2] # [B,H,W]
angle = math.radians(float(angle_deg))
cos_a = math.cos(angle)
sin_a = math.sin(angle)
ys, xs = torch.meshgrid(
torch.arange(H, device=device, dtype=dtype),
torch.arange(W, device=device, dtype=dtype),
indexing="ij",
)
# Rotate the cell grid around the image center.
cx_img = (float(W) - 1.0) / 2.0
cy_img = (float(H) - 1.0) / 2.0
xs_s = xs - cx_img
ys_s = ys - cy_img
rx = (xs_s * cos_a + ys_s * sin_a) / float(sz)
ry = (-xs_s * sin_a + ys_s * cos_a) / float(sz)
cx_cell = torch.floor(rx)
cy_cell = torch.floor(ry)
lx = rx - cx_cell - 0.5 # local cell coord [-0.5, 0.5]
ly = ry - cy_cell - 0.5
# ---- Screen function ----
# Classic halftone: a per-pixel "screen value" in [0,1] that's high in the
# area where the shape is densest and low elsewhere. A pixel prints black
# where the source luminance < screen, white otherwise. As luminance
# decreases, more pixels in the cell fall under the screen → the shape
# grows. Different shape functions create different visual patterns.
sh = (shape or "Dot").strip().lower()
if sh in ("dot", "round", "spot"):
# Round dot: screen = 1 in centre, falls off radially to 0 at the
# cell's farthest corner (distance sqrt(0.5)).
d = torch.sqrt(lx * lx + ly * ly) / 0.7071
screen = 1.0 - torch.clamp(d, 0.0, 1.0)
elif sh == "square dot":
d = 2.0 * torch.maximum(torch.abs(lx), torch.abs(ly))
screen = 1.0 - torch.clamp(d, 0.0, 1.0)
elif sh in ("line", "line centered"):
screen = 1.0 - torch.clamp(2.0 * torch.abs(ly), 0.0, 1.0)
elif sh == "line scalloped":
wave = torch.sin(rx * math.pi * 2.0) * 0.15
screen = 1.0 - torch.clamp(2.0 * torch.abs(ly - wave), 0.0, 1.0)
elif sh in ("rhomboid", "spot diamond"):
d = 2.0 * (torch.abs(lx) + torch.abs(ly))
screen = 1.0 - torch.clamp(d, 0.0, 1.0)
elif sh == "cross cut":
# Cross-shape: high along either axis through the cell centre.
d = 2.0 * torch.minimum(torch.abs(lx), torch.abs(ly))
screen = 1.0 - torch.clamp(d, 0.0, 1.0)
elif sh == "saddle":
d = 4.0 * torch.abs(lx * ly)
screen = 1.0 - torch.clamp(d, 0.0, 1.0)
elif sh == "random dots":
seed = (cx_cell.to(torch.int64) * 73856093 + cy_cell.to(torch.int64) * 19349663).abs() & 0xFFFFFFFF
dx_jit = ((seed % 1000).to(dtype) / 1000.0 - 0.5) * 0.6
dy_jit = ((torch.div(seed, 1000, rounding_mode="floor") % 1000).to(dtype) / 1000.0 - 0.5) * 0.6
d = torch.sqrt((lx - dx_jit) ** 2 + (ly - dy_jit) ** 2) / 0.7071
screen = 1.0 - torch.clamp(d, 0.0, 1.0)
else:
d = torch.sqrt(lx * lx + ly * ly) / 0.7071
screen = 1.0 - torch.clamp(d, 0.0, 1.0)
# ---- Bayer-ordered dithering on the luminance ----
# Adds a stable per-pixel offset (±d_strength*0.5) to the source luminance
# before thresholding against the screen. With d=100 this produces the
# noisy, error-diffusion-like tonal transitions of a real halftone print.
d_strength = max(0, min(100, int(dither))) / 100.0
eff_lum = lum
if d_strength > 0.0:
bayer = torch.tensor(_BAYER_8, device=device, dtype=dtype) / 64.0
bayer_cell = max(1, int(sz) // 5)
ys_i = ((torch.arange(H, device=device) // bayer_cell) % 8).long()
xs_i = ((torch.arange(W, device=device) // bayer_cell) % 8).long()
bayer_full = bayer[ys_i.unsqueeze(1), xs_i.unsqueeze(0)].unsqueeze(0) # [1,H,W]
bayer_offset = (bayer_full - 0.5) * d_strength
eff_lum = lum + bayer_offset
# Threshold: white where the (dithered) luminance is above the screen
# value; black otherwise. This is the canonical halftone-screen rule.
mask = (eff_lum > screen).to(dtype)
# With dither at 0, fall back to a soft mask so the result doesn't look
# like a hard threshold (no noise to smooth tonal transitions). Realistic
# halftone always wants some dither, so the default upstream value is 100.
if d_strength == 0.0:
mask = torch.clamp((eff_lum - screen) * 4.0 + 0.5, 0.0, 1.0)
if bool(inverse):
mask = 1.0 - mask
out = mask.unsqueeze(-1).expand(-1, -1, -1, 3).contiguous()
return out
# ---------- color filter ----------
def _apply_color_filter(
image: torch.Tensor,
hue_deg: float,
saturation: float,
density: int,
preserve_highlights: int,
) -> torch.Tensor:
orig = torch.clamp(image, 0.0, 1.0)
device = orig.device
dtype = orig.dtype
# Build the tint color from HSV (value = 1, full brightness).
h_n = (float(hue_deg) % 360.0) / 360.0
s_n = max(0.0, min(1.0, float(saturation)))
hsv = torch.tensor([h_n, s_n, 1.0], device=device, dtype=dtype).view(1, 1, 1, 3)
filter_color = hsv_to_rgb(hsv) # [1,1,1,3]
density_n = max(0.0, min(1.0, float(density) / 255.0))
preserve_n = max(0.0, min(1.0, float(preserve_highlights) / 100.0))
# Per-pixel effective density: highlights resist the tint based on preserve.
# weight = 1 - preserve * lum -> at preserve=1, lum=1 pixels get weight=0
# (untouched), lum=0 pixels get weight=1 (fully affected). At preserve=0,
# weight=1 for every pixel (uniform full density).
lum = 0.299 * orig[..., 0:1] + 0.587 * orig[..., 1:2] + 0.114 * orig[..., 2:3]
weight = torch.clamp(1.0 - preserve_n * lum, 0.0, 1.0)
eff_density = density_n * weight
tinted = orig * filter_color
out = orig * (1.0 - eff_density) + tinted * eff_density
return torch.clamp(out, 0.0, 1.0)
# ---------- sharpen (unsharp mask) ----------
def _apply_sharpen(image: torch.Tensor, amount: float, radius: int, threshold: int) -> torch.Tensor:
if abs(float(amount)) < 0.001:
return image
r = max(1, int(radius))
blurred = _gaussian_blur(image, r)
diff = image - blurred
thr = max(0, int(threshold))
if thr > 0:
thr_n = float(thr) / 255.0
# Only sharpen where the per-channel detail magnitude exceeds threshold.
keep = (diff.abs() > thr_n).to(image.dtype)
diff = diff * keep
out = image + diff * (float(amount) / 100.0)
return torch.clamp(out, 0.0, 1.0)
# ---------- laplacian sharpen ----------
def _laplacian(image: torch.Tensor, kernel: str) -> torch.Tensor:
"""Second-derivative (Laplacian) of each RGB channel. Uses a 4-neighbor
or 8-neighbor 3x3 kernel with replicate padding so edges stay stable."""
x = image.permute(0, 3, 1, 2).contiguous() # B,C,H,W
C = x.shape[1]
if (kernel or "").find("4") >= 0:
k = torch.tensor([[0.0, 1.0, 0.0], [1.0, -4.0, 1.0], [0.0, 1.0, 0.0]],
device=x.device, dtype=x.dtype)
else:
k = torch.tensor([[1.0, 1.0, 1.0], [1.0, -8.0, 1.0], [1.0, 1.0, 1.0]],
device=x.device, dtype=x.dtype)
k = k.view(1, 1, 3, 3).repeat(C, 1, 1, 1)
x_pad = torch.nn.functional.pad(x, (1, 1, 1, 1), mode="replicate")
lap = torch.nn.functional.conv2d(x_pad, k, groups=C)
return lap.permute(0, 2, 3, 1)
def _apply_laplacian_sharpen(image: torch.Tensor, amount: float, kernel: str) -> torch.Tensor:
if abs(float(amount)) < 0.001:
return image
lap = _laplacian(image, kernel)
# Sharpen rule g = f - k*(Laplacian f): the kernel's negative centre makes
# subtraction lift peaks and deepen troughs, boosting local contrast.
out = image - float(amount) * lap
return torch.clamp(out, 0.0, 1.0)
# ---------- unsharp masking (Gaussian) ----------
def _apply_unsharp_mask(image: torch.Tensor, amount: float, radius: int, threshold: int) -> torch.Tensor:
"""Classic unsharp mask (https://en.wikipedia.org/wiki/Unsharp_masking):
sharpened = original + amount * (original - blurred), gated by a per-channel
threshold. `amount` is a direct multiplier (1.0 = 100%)."""
if abs(float(amount)) < 0.001:
return image
r = max(1, int(radius))
blurred = _gaussian_blur(image, r)
diff = image - blurred
thr = max(0, int(threshold))
if thr > 0:
thr_n = float(thr) / 255.0
keep = (diff.abs() > thr_n).to(image.dtype)
diff = diff * keep
out = image + diff * float(amount)
return torch.clamp(out, 0.0, 1.0)
# ---------- pixelate / mosaic ----------
def _apply_pixelate(image: torch.Tensor, size: int, mode: str) -> torch.Tensor:
bsize = max(1, int(size))
B, H, W, C = image.shape
nh = max(1, H // bsize)
nw = max(1, W // bsize)
x = image.permute(0, 3, 1, 2)
x_small = torch.nn.functional.adaptive_avg_pool2d(x, (nh, nw))
x_big = torch.nn.functional.interpolate(x_small, size=(H, W), mode="nearest")
out = x_big.permute(0, 2, 3, 1)
if (mode or "").strip().lower() == "mosaic" and bsize >= 3:
device = image.device
dtype = image.dtype
ys = torch.arange(H, device=device) % bsize
xs = torch.arange(W, device=device) % bsize
grout_y = (ys < 1).to(dtype)
grout_x = (xs < 1).to(dtype)
mask = torch.maximum(grout_y.unsqueeze(1), grout_x.unsqueeze(0)) # [H,W]
mask = mask.view(1, H, W, 1)
out = out * (1.0 - mask * 0.7)
return out
# ---------- posterize ----------
def _posterize_quantise(
x: torch.Tensor, step: float, dither_mode: str, d_strength: float,
) -> torch.Tensor:
"""Round `x` to `step+1` equally-spaced levels in [0,1] using the chosen
dithering strategy. Operates on any [B,H,W,C] tensor."""
device = x.device
dtype = x.dtype
dm = (dither_mode or "None").strip().lower()
if dm in ("none", "") or d_strength <= 0.0 or dm not in ("bayer", "random", "floyd-steinberg", "atkinson"):
return torch.round(torch.clamp(x, 0.0, 1.0) * step) / step
if dm == "bayer":
_, H, W, _ = x.shape
bayer = torch.tensor(_BAYER_8, device=device, dtype=dtype) / 64.0
ys_i = (torch.arange(H, device=device) % 8).long()
xs_i = (torch.arange(W, device=device) % 8).long()
bayer_full = bayer[ys_i.unsqueeze(1), xs_i.unsqueeze(0)]
bayer_full = bayer_full.unsqueeze(0).unsqueeze(-1)
offset = (bayer_full - 0.5) * (d_strength / max(1.0, step))
return torch.round(torch.clamp(x + offset, 0.0, 1.0) * step) / step
if dm == "random":
noise = (torch.rand_like(x) - 0.5) * (d_strength / max(1.0, step))
return torch.round(torch.clamp(x + noise, 0.0, 1.0) * step) / step
# Error-diffusion variants (Floyd-Steinberg, Atkinson) — fundamentally
# sequential. Run on CPU via numpy for speed; strength is ignored (these
# algorithms always propagate full error).
arr = x.detach().cpu().numpy().astype(np.float32).copy()
B_, H_, W_, C_ = arr.shape
if dm == "floyd-steinberg":
for b in range(B_):
for c in range(C_):
plane = arr[b, :, :, c]
for y in range(H_):
for xx in range(W_):
old = plane[y, xx]
new = round(old * step) / step
err = old - new
plane[y, xx] = new
if xx + 1 < W_:
plane[y, xx + 1] += err * (7.0 / 16.0)
if y + 1 < H_:
if xx > 0:
plane[y + 1, xx - 1] += err * (3.0 / 16.0)
plane[y + 1, xx] += err * (5.0 / 16.0)
if xx + 1 < W_:
plane[y + 1, xx + 1] += err * (1.0 / 16.0)
else: # atkinson
for b in range(B_):
for c in range(C_):
plane = arr[b, :, :, c]
for y in range(H_):
for xx in range(W_):
old = plane[y, xx]
new = round(old * step) / step
err = (old - new) / 8.0
plane[y, xx] = new
if xx + 1 < W_: plane[y, xx + 1] += err
if xx + 2 < W_: plane[y, xx + 2] += err
if y + 1 < H_:
if xx > 0: plane[y + 1, xx - 1] += err
plane[y + 1, xx] += err
if xx + 1 < W_: plane[y + 1, xx + 1] += err
if y + 2 < H_:
plane[y + 2, xx] += err
np.clip(arr, 0.0, 1.0, out=arr)
return torch.from_numpy(arr).to(device=device, dtype=dtype)
def _apply_posterize(
image: torch.Tensor,
levels: int,
mode: str = "RGB",
dither: int = 0,
dither_mode: str = "None",
) -> torch.Tensor:
l = max(2, min(64, int(levels)))
step = float(l - 1)
m = (mode or "RGB").strip().lower()
d_strength = max(0, min(100, int(dither))) / 100.0
if m == "luminance":
lum = 0.299 * image[..., 0:1] + 0.587 * image[..., 1:2] + 0.114 * image[..., 2:3]
lum_q = _posterize_quantise(lum.expand(-1, -1, -1, 1), step, dither_mode, d_strength)
ratio = torch.where(lum > 1e-4, lum_q / (lum + 1e-8), torch.ones_like(lum))
return torch.clamp(image * ratio, 0.0, 1.0)
return torch.clamp(_posterize_quantise(image, step, dither_mode, d_strength), 0.0, 1.0)
# ---------- vignette ----------
def _apply_vignette(
image: torch.Tensor,
amount: float, size: float, feather: float, roundness: float,
center_x: float = 50.0, center_y: float = 50.0,
) -> torch.Tensor:
B, H, W, C = image.shape
device = image.device
dtype = image.dtype
ys, xs = torch.meshgrid(
torch.arange(H, device=device, dtype=dtype),
torch.arange(W, device=device, dtype=dtype),
indexing="ij",
)
cx_px = (float(center_x) / 100.0) * float(max(1, W - 1))
cy_px = (float(center_y) / 100.0) * float(max(1, H - 1))
nx = (xs - cx_px) / max(1.0, (W - 1) / 2.0)
ny = (ys - cy_px) / max(1.0, (H - 1) / 2.0)
r_n = float(roundness) / 100.0
if r_n > 0:
# Stretch in Y: makes vignette taller (oval portrait)
ny = ny * (1.0 + r_n)
elif r_n < 0:
nx = nx * (1.0 - r_n)
dist = torch.sqrt(nx * nx + ny * ny)
inner = max(0.0, float(size) / 100.0)
feather_n = max(0.001, float(feather) / 100.0)
outer = inner + feather_n
factor = torch.clamp((outer - dist) / (outer - inner + 1e-6), 0.0, 1.0)
factor = factor * factor * (3.0 - 2.0 * factor) # smoothstep
factor = factor.unsqueeze(0).unsqueeze(-1) # [1,H,W,1]
amt = float(amount) / 100.0
if amt >= 0:
out = image * (1.0 - (1.0 - factor) * amt)
else:
out = image + (1.0 - image) * (1.0 - factor) * (-amt)
return torch.clamp(out, 0.0, 1.0)
# ---------- levels ----------
def _apply_levels(
image: torch.Tensor,
channel: str,
in_black: int,
in_white: int,
gamma: float,
out_black: int,
out_white: int,
) -> torch.Tensor:
in_b = float(in_black) / 255.0
in_w = float(in_white) / 255.0
out_b = float(out_black) / 255.0
out_w = float(out_white) / 255.0
g = max(0.01, float(gamma))
in_range = max(1.0 / 255.0, in_w - in_b)
def apply_to(c: torch.Tensor) -> torch.Tensor:
x = (c - in_b) / in_range
x = torch.clamp(x, 0.0, 1.0)
x = torch.pow(x, 1.0 / g)
x = out_b + x * (out_w - out_b)
return torch.clamp(x, 0.0, 1.0)
ch = (channel or "RGB").strip().upper()
if ch == "RGB":
return apply_to(image)
idx_map = {"R": 0, "RED": 0, "G": 1, "GREEN": 1, "B": 2, "BLUE": 2}
if ch in idx_map:
idx = idx_map[ch]
out = image.clone()
out[..., idx] = apply_to(image[..., idx])
return out
return apply_to(image)
# ---------- color balance ----------
def _apply_color_balance(
image: torch.Tensor,
sh_r: float, sh_g: float, sh_b: float,
mid_r: float, mid_g: float, mid_b: float,
hi_r: float, hi_g: float, hi_b: float,
) -> torch.Tensor:
lum = 0.299 * image[..., 0:1] + 0.587 * image[..., 1:2] + 0.114 * image[..., 2:3]
shadow_w = torch.clamp(1.0 - 2.0 * lum, 0.0, 1.0)
midtone_w = 1.0 - torch.abs(2.0 * lum - 1.0)
highlight_w = torch.clamp(2.0 * lum - 1.0, 0.0, 1.0)
s = 1.0 / 200.0 # so a full +100 nudge moves a channel by at most 0.5
shift_r = (float(sh_r) * s) * shadow_w + (float(mid_r) * s) * midtone_w + (float(hi_r) * s) * highlight_w
shift_g = (float(sh_g) * s) * shadow_w + (float(mid_g) * s) * midtone_w + (float(hi_g) * s) * highlight_w
shift_b = (float(sh_b) * s) * shadow_w + (float(mid_b) * s) * midtone_w + (float(hi_b) * s) * highlight_w
r = image[..., 0:1] + shift_r
g = image[..., 1:2] + shift_g
b = image[..., 2:3] + shift_b
out = torch.cat([r, g, b], dim=-1)
return torch.clamp(out, 0.0, 1.0)
# ---------- main dispatch ----------
def apply_image_effect(
image: torch.Tensor,
effect: str,
source: str,
radius: int,
blend_mode: str,
halftone_shape: str = "Dot",
halftone_inverse: bool = False,
halftone_size: int = 18,
halftone_angle: float = 15.0,
halftone_contrast: int = 0,
halftone_brightness: int = 0,
halftone_quality: int = 2,
halftone_dither: int = 100,
color_hue: int = 0,
color_saturation: float = 1.0,
color_density: int = 128,
color_preserve_highlights: int = 50,
sharpen_amount: int = 50,
sharpen_radius: int = 2,
sharpen_threshold: int = 0,
pixelate_size: int = 16,
pixelate_mode: str = "Pixelate",
posterize_levels: int = 4,
vignette_amount: int = 50,
vignette_size: int = 50,
vignette_feather: int = 30,
vignette_roundness: int = 0,
levels_channel: str = "RGB",
levels_in_black: int = 0,
levels_in_white: int = 255,
levels_gamma: float = 1.0,
levels_out_black: int = 0,
levels_out_white: int = 255,
cb_shadow_red: int = 0, cb_shadow_green: int = 0, cb_shadow_blue: int = 0,
cb_midtone_red: int = 0, cb_midtone_green: int = 0, cb_midtone_blue: int = 0,
cb_highlight_red: int = 0, cb_highlight_green: int = 0, cb_highlight_blue: int = 0,
blur_mode: str = "Gaussian",
posterize_mode: str = "RGB",
posterize_dither: int = 0,
posterize_dither_mode: str = "None",
vignette_center_x: int = 50,
vignette_center_y: int = 50,
laplacian_amount: float = 1.0,
laplacian_kernel: str = "3x3 (8-neighbor)",
usm_amount: float = 1.0,
usm_radius: int = 3,
usm_threshold: int = 0,
mask=None,
) -> torch.Tensor:
orig = torch.clamp(image, 0.0, 1.0)
eff = (effect or "").strip().lower()
if eff == "halftone":
# Dithering is always on (Bayer 8x8 ordered dithering, scaled by size).
# The halftone_dither widget is kept in INPUT_TYPES for back-compat
# but its value is ignored.
processed = _apply_halftone(
orig, halftone_shape, halftone_inverse,
halftone_size, halftone_angle,
halftone_contrast, halftone_brightness, halftone_quality,
100,
)
elif eff == "color filter":
processed = _apply_color_filter(
orig, color_hue, color_saturation, color_density, color_preserve_highlights,
)
elif eff == "sharpen":
processed = _apply_sharpen(orig, sharpen_amount, sharpen_radius, sharpen_threshold)
elif eff == "laplacian sharpen":
processed = _apply_laplacian_sharpen(orig, laplacian_amount, laplacian_kernel)
elif eff == "unsharp masking":
processed = _apply_unsharp_mask(orig, usm_amount, usm_radius, usm_threshold)
elif eff == "pixelate":
processed = _apply_pixelate(orig, pixelate_size, pixelate_mode)
elif eff == "posterize":
processed = _apply_posterize(orig, posterize_levels, posterize_mode, posterize_dither, posterize_dither_mode)
elif eff == "vignette":
processed = _apply_vignette(
orig, vignette_amount, vignette_size, vignette_feather, vignette_roundness,
vignette_center_x, vignette_center_y,
)
elif eff == "levels":
processed = _apply_levels(
orig, levels_channel,
levels_in_black, levels_in_white, levels_gamma,
levels_out_black, levels_out_white,
)
elif eff == "color balance":
processed = _apply_color_balance(
orig,
cb_shadow_red, cb_shadow_green, cb_shadow_blue,
cb_midtone_red, cb_midtone_green, cb_midtone_blue,
cb_highlight_red, cb_highlight_green, cb_highlight_blue,
)
else:
# Resolve the actual blur algorithm. "Blur" effect uses blur_mode;
# legacy effect names ("Average Blur" etc.) from old workflows are
# accepted as direct aliases.
if eff == "blur":
blur_effect = _resolve_blur_effect_name(blur_mode)
else:
blur_effect = eff # legacy: "average blur" / "gaussian blur" / "average edge blur"
name_map = {
"RED": "R", "GREEN": "G", "BLUE": "B",
"RED+GREEN": "R+G", "RED+BLUE": "R+B", "GREEN+BLUE": "G+B",
"HUE": "H", "SATURATION": "S", "VALUE": "V",
"RGB": "RGB", "HSV": "HSV", "OKLAB": "OKLAB",
}
s = name_map.get((source or "RGB").strip().upper(), "RGB")
if s in RGB_INDEX_GROUPS:
processed = _blur_rgb_indices(orig, RGB_INDEX_GROUPS[s], blur_effect, radius)
elif s in ("H", "S", "V"):
hsv = rgb_to_hsv(orig).clone()
idx = {"H": 0, "S": 1, "V": 2}[s]
if idx == 0:
hsv[..., 0] = _blur_hue(hsv[..., 0], blur_effect, radius)
else:
ch = hsv[..., idx:idx + 1]
hsv[..., idx:idx + 1] = torch.clamp(_apply_blur_effect(ch, blur_effect, radius), 0.0, 1.0)
processed = hsv_to_rgb(hsv)
elif s == "HSV":
hsv = rgb_to_hsv(orig)
h_b = _blur_hue(hsv[..., 0], blur_effect, radius)
sv_b = torch.clamp(_apply_blur_effect(hsv[..., 1:3], blur_effect, radius), 0.0, 1.0)
hsv2 = torch.stack([h_b, sv_b[..., 0], sv_b[..., 1]], dim=-1)
processed = hsv_to_rgb(hsv2)
elif s == "OKLAB":
lab = rgb_to_oklab(orig)
lab_b = _apply_blur_effect(lab, blur_effect, radius)
processed = oklab_to_rgb(lab_b)
else:
processed = _apply_blur_effect(orig, blur_effect, radius)
out = apply_blend(orig, processed, blend_mode, 255)
# If a mask was provided, restrict the entire effect (including the blend)
# to the masked region — pixels outside the mask keep the original image.
if mask is not None:
out = apply_mask_blend(orig, out, mask)
return torch.clamp(out, 0.0, 1.0)
class ImageEffectsNode:
@classmethod
def INPUT_TYPES(cls):
# Widget order is append-only across versions so older workflows keep
# mapping their saved positional widgets_values to the right widgets.
return {
"required": {
"image": ("IMAGE",),
"effect": (EFFECT_CHOICES, {"default": "Blur"}),
"radius": ("INT", {"default": 8, "min": 0, "max": 200, "step": 1}),
"source": (SOURCE_CHOICES, {"default": "RGB"}),
"blend_mode": (BLEND_MODE_CHOICES, {"default": "Normal"}),
},
"optional": {
# Halftone-specific widgets. Hidden by JS unless effect == Halftone.
"halftone_shape": (HALFTONE_SHAPES, {"default": "Dot"}),
"halftone_inverse": ("BOOLEAN", {"default": False}),
"halftone_size": ("INT", {"default": 12, "min": 1, "max": 100, "step": 1}),
"halftone_angle": ("FLOAT", {"default": 15.0, "min": -180.0, "max": 180.0, "step": 0.1}),
"halftone_contrast": ("INT", {"default": 0, "min": -100, "max": 100, "step": 1}),
"halftone_brightness": ("INT", {"default": 0, "min": -100, "max": 100, "step": 1}),
"halftone_quality": ("INT", {"default": 2, "min": 1, "max": 5, "step": 1}),
"halftone_dither": ("INT", {"default": 100, "min": 0, "max": 100, "step": 1}),
# Color Filter widgets. color_saturation is kept hidden in the
# UI and only written by the custom color-wheel widget.
"color_hue": ("INT", {"default": 0, "min": 0, "max": 360, "step": 1}),
"color_density": ("INT", {"default": 128, "min": 0, "max": 255, "step": 1}),
"color_preserve_highlights": ("INT", {"default": 50, "min": 0, "max": 100, "step": 1}),
"color_saturation": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.001}),
# Sharpen (unsharp mask) widgets. Negative amount = soften.
"sharpen_amount": ("INT", {"default": 50, "min": -200, "max": 200, "step": 1}),
"sharpen_radius": ("INT", {"default": 2, "min": 1, "max": 20, "step": 1}),
"sharpen_threshold": ("INT", {"default": 0, "min": 0, "max": 50, "step": 1}),
# Pixelate / Mosaic.
"pixelate_size": ("INT", {"default": 16, "min": 1, "max": 200, "step": 1}),
"pixelate_mode": (PIXELATE_MODES, {"default": "Pixelate"}),
# Posterize.
"posterize_levels": ("INT", {"default": 4, "min": 2, "max": 32, "step": 1}),
# Vignette.
"vignette_amount": ("INT", {"default": 50, "min": -100, "max": 100, "step": 1}),
"vignette_size": ("INT", {"default": 50, "min": 0, "max": 100, "step": 1}),
"vignette_feather": ("INT", {"default": 30, "min": 0, "max": 100, "step": 1}),
"vignette_roundness": ("INT", {"default": 0, "min": -100, "max": 100, "step": 1}),
# Levels.
"levels_channel": (LEVELS_CHANNELS, {"default": "RGB"}),
"levels_in_black": ("INT", {"default": 0, "min": 0, "max": 254, "step": 1}),
"levels_in_white": ("INT", {"default": 255, "min": 1, "max": 255, "step": 1}),
"levels_gamma": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 9.99, "step": 0.01}),
"levels_out_black": ("INT", {"default": 0, "min": 0, "max": 254, "step": 1}),
"levels_out_white": ("INT", {"default": 255, "min": 1, "max": 255, "step": 1}),
# Color Balance (per tonal range, per channel; negative = complementary).
"cb_shadow_red": ("INT", {"default": 0, "min": -100, "max": 100, "step": 1}),
"cb_shadow_green": ("INT", {"default": 0, "min": -100, "max": 100, "step": 1}),
"cb_shadow_blue": ("INT", {"default": 0, "min": -100, "max": 100, "step": 1}),
"cb_midtone_red": ("INT", {"default": 0, "min": -100, "max": 100, "step": 1}),
"cb_midtone_green": ("INT", {"default": 0, "min": -100, "max": 100, "step": 1}),
"cb_midtone_blue": ("INT", {"default": 0, "min": -100, "max": 100, "step": 1}),
"cb_highlight_red": ("INT", {"default": 0, "min": -100, "max": 100, "step": 1}),
"cb_highlight_green": ("INT", {"default": 0, "min": -100, "max": 100, "step": 1}),
"cb_highlight_blue": ("INT", {"default": 0, "min": -100, "max": 100, "step": 1}),
# Blur mode (for the combined "Blur" effect).
"blur_mode": (BLUR_MODES, {"default": "Gaussian"}),
# Posterize extras.
"posterize_mode": (POSTERIZE_MODES, {"default": "RGB"}),
"posterize_dither": ("INT", {"default": 0, "min": 0, "max": 100, "step": 1}),
# Vignette center (0-100 percent of image dimensions).
"vignette_center_x": ("INT", {"default": 50, "min": 0, "max": 100, "step": 1}),
"vignette_center_y": ("INT", {"default": 50, "min": 0, "max": 100, "step": 1}),
"posterize_dither_mode": (POSTERIZE_DITHER_MODES, {"default": "None"}),
# Laplacian Sharpen.
"laplacian_amount": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.05}),
"laplacian_kernel": (LAPLACIAN_KERNELS, {"default": "3x3 (8-neighbor)"}),
# Unsharp Masking (Gaussian). amount is a direct multiplier.
"usm_amount": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.05}),
"usm_radius": ("INT", {"default": 3, "min": 1, "max": 50, "step": 1}),
"usm_threshold": ("INT", {"default": 0, "min": 0, "max": 255, "step": 1}),
"mask": ("MASK",),
"preview_id": ("STRING", {"default": "A"}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "run"
CATEGORY = "image/processing"
def run(self, image, effect, radius, source, blend_mode,
preview_id: str = "A", **kwargs):
# **kwargs lets us add new effect-specific widgets in INPUT_TYPES["optional"]
# without changing this signature.
out = apply_image_effect(
image, effect, source, radius, blend_mode,
halftone_shape=kwargs.get("halftone_shape", "Dot"),
halftone_inverse=bool(kwargs.get("halftone_inverse", False)),
halftone_size=int(kwargs.get("halftone_size", 18)),
halftone_angle=float(kwargs.get("halftone_angle", 15.0)),
halftone_contrast=int(kwargs.get("halftone_contrast", 0)),
halftone_brightness=int(kwargs.get("halftone_brightness", 0)),
halftone_quality=int(kwargs.get("halftone_quality", 2)),
halftone_dither=int(kwargs.get("halftone_dither", 100)),
color_hue=int(kwargs.get("color_hue", 0)),
# Saturation is fixed at 1.0 — the color wheel only writes hue +
# density now. The widget remains in INPUT_TYPES for positional
# widgets_values back-compat, but its value is ignored.
color_saturation=1.0,
color_density=int(kwargs.get("color_density", 128)),
color_preserve_highlights=int(kwargs.get("color_preserve_highlights", 50)),
sharpen_amount=int(kwargs.get("sharpen_amount", 50)),
sharpen_radius=int(kwargs.get("sharpen_radius", 2)),
sharpen_threshold=int(kwargs.get("sharpen_threshold", 0)),
pixelate_size=int(kwargs.get("pixelate_size", 16)),
pixelate_mode=str(kwargs.get("pixelate_mode", "Pixelate")),
posterize_levels=int(kwargs.get("posterize_levels", 4)),
vignette_amount=int(kwargs.get("vignette_amount", 50)),
vignette_size=int(kwargs.get("vignette_size", 50)),
vignette_feather=int(kwargs.get("vignette_feather", 30)),
vignette_roundness=int(kwargs.get("vignette_roundness", 0)),
levels_channel=str(kwargs.get("levels_channel", "RGB")),
levels_in_black=int(kwargs.get("levels_in_black", 0)),
levels_in_white=int(kwargs.get("levels_in_white", 255)),
levels_gamma=float(kwargs.get("levels_gamma", 1.0)),
levels_out_black=int(kwargs.get("levels_out_black", 0)),
levels_out_white=int(kwargs.get("levels_out_white", 255)),
cb_shadow_red=int(kwargs.get("cb_shadow_red", 0)),
cb_shadow_green=int(kwargs.get("cb_shadow_green", 0)),
cb_shadow_blue=int(kwargs.get("cb_shadow_blue", 0)),
cb_midtone_red=int(kwargs.get("cb_midtone_red", 0)),
cb_midtone_green=int(kwargs.get("cb_midtone_green", 0)),
cb_midtone_blue=int(kwargs.get("cb_midtone_blue", 0)),
cb_highlight_red=int(kwargs.get("cb_highlight_red", 0)),
cb_highlight_green=int(kwargs.get("cb_highlight_green", 0)),
cb_highlight_blue=int(kwargs.get("cb_highlight_blue", 0)),
blur_mode=str(kwargs.get("blur_mode", "Gaussian")),
posterize_mode=str(kwargs.get("posterize_mode", "RGB")),
posterize_dither=int(kwargs.get("posterize_dither", 0)),
vignette_center_x=int(kwargs.get("vignette_center_x", 50)),
vignette_center_y=int(kwargs.get("vignette_center_y", 50)),
posterize_dither_mode=str(kwargs.get("posterize_dither_mode", "None")),
laplacian_amount=float(kwargs.get("laplacian_amount", 1.0)),
laplacian_kernel=str(kwargs.get("laplacian_kernel", "3x3 (8-neighbor)")),
usm_amount=float(kwargs.get("usm_amount", 1.0)),
usm_radius=int(kwargs.get("usm_radius", 3)),
usm_threshold=int(kwargs.get("usm_threshold", 0)),
mask=kwargs.get("mask"),
)
this_dir = os.path.dirname(os.path.abspath(__file__))
web_dir = os.path.join(this_dir, "web")
safe_id = ''.join(ch if ch.isalnum() or ch in ('-', '_') else '_' for ch in (preview_id or 'A'))
fname_src = f"image_effects_src_{safe_id}.png"
_save_web_preview(image, web_dir, filename=fname_src, max_dim=512)
try:
from server import PromptServer # type: ignore
try:
orig_h = int(image.shape[1])
orig_w = int(image.shape[2])
except Exception:
orig_h = orig_w = 0
payload = {
"preview_id": safe_id,
"ts": time.time(),
"orig_w": orig_w,
"orig_h": orig_h,
}
PromptServer.instance.send_sync("image_effects_preview", payload)
except Exception:
pass
return (out,)