Files
l33chKing f3344769d2 Add Color Matching Ops node, new sharpen filters, and preview fixes
New ColorMatchingNode (Color Matching Ops): image + reference inputs, one
output. Methods LAB/RGB/Histogram (LAB default) and a target selector
(All/Lightness/Color) that keeps only the matched Oklab lightness or color.
Statistics-only, so inputs may be any size. Live in-node preview.

Filter Ops: add Laplacian Sharpen (4-/8-neighbor kernel) and Unsharp Masking
(Gaussian amount/radius/threshold), with matching JS live previews, widget
visibility, and tooltips.

Layer Blending: rename to Layer Blending Ops, drop the unused preview_ref
output, and make the live preview match the backend exactly (resize fg to bg
size, default opacity 255, per-mode clamp parity) - fixes big mismatches e.g.
Linear Dodge (Add) at high opacity.

Rename Color Matching node display to Color Matching Ops. Raise color-match
preview resolution to 1024 to tighten preview-vs-output stats.

Previews: use widget.last_y to stop the preview box overlapping the last
widget (all nodes, incl. Channel Ops); hide the preview box until a preview
exists; auto-size the node to a sensible preview area on first preview; keep
fresh Filter Ops compact instead of huge with empty space.

README: add per-node preview GIFs (channel_ops/Layer_blend/filter_ops/
color_match) and document the new node/effects; drop the old preview.gif
reference.
2026-07-03 13:34:58 +03:00

293 lines
9.2 KiB
Python

import os
import time
from typing import Dict, Any, Tuple
import torch
import numpy as np
# Reuse preview helpers similar to channel_ops
def _downscale_for_preview(img: torch.Tensor, max_dim: int = 512) -> torch.Tensor:
if max_dim is None or max_dim <= 0:
return img
b, h, w, c = img.shape
if max(h, w) <= max_dim:
return img
scale = max_dim / float(max(h, w))
nh, nw = max(1, int(round(h * scale))), max(1, int(round(w * scale)))
x = img.permute(0, 3, 1, 2)
x = torch.nn.functional.interpolate(x, size=(nh, nw), mode="bilinear", align_corners=False)
return x.permute(0, 2, 3, 1)
def _save_web_preview(img: torch.Tensor, web_dir: str, filename: str, max_dim: int = 512) -> None:
try:
os.makedirs(web_dir, exist_ok=True)
try:
from PIL import Image as _PILImage # type: ignore
except Exception:
return
img_ds = _downscale_for_preview(img.detach().to("cpu"), max_dim=max_dim)
b, h, w, c = img_ds.shape
if b < 1:
return
arr = (img_ds[0].clamp(0.0, 1.0).numpy() * 255.0).astype(np.uint8)
pil = _PILImage.fromarray(arr, mode="RGB")
out_path = os.path.join(web_dir, filename)
pil.save(out_path, format="PNG")
except Exception as e:
print(f"[LayerBlendingNode] Preview save failed: {e}")
def _align_batches(a: torch.Tensor, b: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
if a.ndim != 4 or b.ndim != 4:
return a, b
ba = a.shape[0]
bb = b.shape[0]
if ba == bb:
return a, b
m = min(ba, bb)
return a[:m], b[:m]
def _resize_hw(x: torch.Tensor, new_hw: Tuple[int, int]) -> torch.Tensor:
if x.ndim != 4 or x.shape[-1] != 3:
return x
b, h, w, c = x.shape
nh, nw = new_hw
if h == nh and w == nw:
return x
xc = x.permute(0, 3, 1, 2)
xr = torch.nn.functional.interpolate(xc, size=(nh, nw), mode="bilinear", align_corners=False)
return xr.permute(0, 2, 3, 1)
def _blend_normal(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
return b
def _blend_multiply(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
return a * b
def _blend_screen(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
return 1.0 - (1.0 - a) * (1.0 - b)
def _blend_overlay(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
# Overlay: base A, blend B
return torch.where(a <= 0.5, 2.0 * a * b, 1.0 - 2.0 * (1.0 - a) * (1.0 - b))
def _blend_soft_light(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
# Simplified soft light approximation: (1-2B)*A^2 + 2B*A
return (1.0 - 2.0 * b) * (a * a) + 2.0 * b * a
def _blend_add(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
return a + b
def _blend_subtract(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
return a - b
def _blend_darken(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
return torch.minimum(a, b)
def _blend_lighten(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
return torch.maximum(a, b)
def _blend_color_dodge(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
eps = 1e-8
return torch.clamp(a / torch.clamp(1.0 - b, min=eps), 0.0, 1.0)
def _blend_color_burn(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
eps = 1e-8
return 1.0 - torch.clamp((1.0 - a) / torch.clamp(b, min=eps), 0.0, 1.0)
def _blend_linear_burn(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
return a + b - 1.0
def _blend_vivid_light(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
# If B<0.5 -> Color Burn(A, 2B), else Color Dodge(A, 2B-1)
low_mask = b < 0.5
b1 = torch.clamp(2.0 * b, 0.0, 1.0)
b2 = torch.clamp(2.0 * (b - 0.5), 0.0, 1.0)
out_burn = _blend_color_burn(a, b1)
out_dodge = _blend_color_dodge(a, b2)
return torch.where(low_mask, out_burn, out_dodge)
def _blend_linear_light(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
return a + 2.0 * b - 1.0
def _blend_pin_light(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
# If B<0.5 -> min(A, 2B), else max(A, 2B-1)
low_mask = b < 0.5
low = torch.minimum(a, torch.clamp(2.0 * b, 0.0, 1.0))
high = torch.maximum(a, torch.clamp(2.0 * b - 1.0, 0.0, 1.0))
return torch.where(low_mask, low, high)
def _blend_hard_light(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
return torch.where(b <= 0.5, 2.0 * a * b, 1.0 - 2.0 * (1.0 - a) * (1.0 - b))
def _blend_difference(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
return torch.abs(a - b)
def _blend_exclusion(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
return a + b - 2.0 * a * b
def _blend_divide(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
eps = 1e-8
return torch.clamp(a / torch.clamp(b, min=eps), 0.0, 1.0)
def _blend_hard_mix(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
# Threshold of linear light approximation: A + B >= 1 -> 1 else 0
return torch.where(a + b < 1.0, torch.zeros_like(a), torch.ones_like(a))
_BLEND_FUNCS: Dict[str, Any] = {
"normal": _blend_normal,
"multiply": _blend_multiply,
"screen": _blend_screen,
"overlay": _blend_overlay,
"soft_light": _blend_soft_light,
"hard_light": _blend_hard_light,
"add": _blend_add,
"linear_dodge": _blend_add,
"subtract": _blend_subtract,
"divide": _blend_divide,
"darken": _blend_darken,
"lighten": _blend_lighten,
"color_dodge": _blend_color_dodge,
"color_burn": _blend_color_burn,
"linear_burn": _blend_linear_burn,
"vivid_light": _blend_vivid_light,
"linear_light": _blend_linear_light,
"pin_light": _blend_pin_light,
"difference": _blend_difference,
"exclusion": _blend_exclusion,
"hard_mix": _blend_hard_mix,
}
def _norm_mode(mode: str) -> str:
m = (mode or "").strip().lower()
# map synonyms and spaces to underscores
m = m.replace("(", " ").replace(")", " ")
m = "_".join([t for t in m.split() if t])
synonyms = {
"linear_dodge_add": "linear_dodge",
"softlight": "soft_light",
"hardlight": "hard_light",
"colordodge": "color_dodge",
"colorburn": "color_burn",
"linearlight": "linear_light",
"linearburn": "linear_burn",
"pinlight": "pin_light",
"hardmix": "hard_mix",
}
return synonyms.get(m, m)
def apply_blend(bg: torch.Tensor, fg: torch.Tensor, mode: str, opacity_255: float) -> torch.Tensor:
# bg, fg: [B,H,W,3], values in [0,1]
# Align batches and spatial sizes
fg, bg = _align_batches(fg, bg)
_, h, w, _ = bg.shape
fg = _resize_hw(fg, (h, w))
# Clamp inputs just in case
bg = torch.clamp(bg, 0.0, 1.0)
fg = torch.clamp(fg, 0.0, 1.0)
key = _norm_mode(mode)
blend_fn = _BLEND_FUNCS.get(key, _blend_normal)
blended = blend_fn(bg, fg)
# Opacity in [0,255] -> normalized
alpha = float(opacity_255) / 255.0
alpha = max(0.0, min(1.0, alpha))
out = bg * (1.0 - alpha) + blended * alpha
return torch.clamp(out, 0.0, 1.0)
class LayerBlendingNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image_bg": ("IMAGE",),
"image_fg": ("IMAGE",),
"mode": ([
"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"
], {"default": "Normal"}),
},
"optional": {
"opacity": ("INT", {"default": 255, "min": 0, "max": 255, "step": 1}),
"preview_id": ("STRING", {"default": "A"}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "run"
CATEGORY = "image/compose"
def run(self, image_bg, image_fg, mode, opacity=255, preview_id: str = "A"):
out = apply_blend(image_bg, image_fg, mode, opacity)
# Save previews for live UI: background and foreground
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_bg = f"layer_blend_bg_{safe_id}.png"
fname_fg = f"layer_blend_fg_{safe_id}.png"
_save_web_preview(image_bg, web_dir, filename=fname_bg, max_dim=512)
_save_web_preview(image_fg, web_dir, filename=fname_fg, max_dim=512)
# Send event to trigger frontend reload
try:
from server import PromptServer # type: ignore
payload = {
"preview_id": safe_id,
"ts": time.time(),
}
PromptServer.instance.send_sync("layer_blend_preview", payload)
except Exception:
pass
return (out,)