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.
293 lines
9.2 KiB
Python
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,)
|