Files
2026-01-30 00:03:30 -08:00

421 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Blending and Compositing Utilities for TextureAlchemy
Advanced image blending with multiple modes and layer support
"""
import torch
import torch.nn.functional as F
class BlendModeUtility:
"""
Apply Photoshop-style blend modes between two images
Supports all major blend modes for texture compositing
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"base": ("IMAGE",),
"blend": ("IMAGE",),
"mode": ([
"normal", "multiply", "screen", "overlay", "soft_light", "hard_light",
"color_dodge", "color_burn", "linear_dodge", "linear_burn",
"vivid_light", "linear_light", "pin_light", "hard_mix",
"difference", "exclusion", "subtract", "divide",
"hue", "saturation", "color", "luminosity",
"lighter", "darker", "lighten", "darken"
],),
"opacity": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"display": "number",
"tooltip": "Layer opacity (0.0 = fully transparent, 1.0 = fully opaque)"
}),
},
"optional": {
"mask": ("IMAGE",),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("result",)
FUNCTION = "apply_blend"
CATEGORY = "Texture Alchemist/Blending"
def apply_blend(self, base, blend, mode, opacity, mask=None):
"""Apply blend mode between two images"""
print(f"\n{'='*60}")
print(f"Blend Mode Utility - {mode.upper()}")
print(f"{'='*60}")
print(f"Base: {base.shape}")
print(f"Blend: {blend.shape}")
# Match channel count first
base_channels = base.shape[3]
blend_channels = blend.shape[3]
if base_channels != blend_channels:
print(f" Channel mismatch: base={base_channels}, blend={blend_channels}")
if base_channels == 3 and blend_channels == 4:
# Base is RGB, blend is RGBA - drop alpha from blend
blend = blend[:, :, :, :3]
print(f" Dropped alpha channel from blend layer")
elif base_channels == 4 and blend_channels == 3:
# Base is RGBA, blend is RGB - add alpha to blend
alpha_channel = torch.ones((blend.shape[0], blend.shape[1], blend.shape[2], 1),
device=blend.device, dtype=blend.dtype)
blend = torch.cat([blend, alpha_channel], dim=-1)
print(f" Added alpha channel to blend layer")
else:
print(f" Warning: Unhandled channel mismatch")
# Ensure same dimensions
if base.shape[1:3] != blend.shape[1:3]:
print(f" Resizing blend: {blend.shape[2]}×{blend.shape[1]} → {base.shape[2]}×{base.shape[1]}")
blend = F.interpolate(
blend.permute(0, 3, 1, 2),
size=(base.shape[1], base.shape[2]),
mode='bilinear',
align_corners=False
).permute(0, 2, 3, 1)
# Apply blend mode
result = self._blend(base, blend, mode)
# Apply opacity
if opacity < 1.0:
result = base * (1.0 - opacity) + result * opacity
# Apply mask if provided
if mask is not None:
if mask.shape != base.shape:
mask = F.interpolate(
mask.permute(0, 3, 1, 2),
size=(base.shape[1], base.shape[2]),
mode='bilinear',
align_corners=False
).permute(0, 2, 3, 1)
# Convert to grayscale if needed
if mask.shape[3] > 1:
mask = mask.mean(dim=3, keepdim=True)
result = base * (1.0 - mask) + result * mask
result = torch.clamp(result, 0.0, 1.0)
print(f"✓ Blend applied: {mode}")
print(f" Opacity: {opacity:.2f}")
print(f" Mask: {'Yes' if mask is not None else 'No'}")
print(f"{'='*60}\n")
return (result,)
def _blend(self, base, blend, mode):
"""Apply specific blend mode"""
# Basic blend modes
if mode == "normal":
return blend
elif mode == "multiply":
return base * blend
elif mode == "screen":
return 1.0 - (1.0 - base) * (1.0 - blend)
elif mode == "overlay":
return torch.where(
base < 0.5,
2.0 * base * blend,
1.0 - 2.0 * (1.0 - base) * (1.0 - blend)
)
elif mode == "soft_light":
return torch.where(
blend < 0.5,
2.0 * base * blend + base * base * (1.0 - 2.0 * blend),
2.0 * base * (1.0 - blend) + torch.sqrt(base) * (2.0 * blend - 1.0)
)
elif mode == "hard_light":
return torch.where(
blend < 0.5,
2.0 * base * blend,
1.0 - 2.0 * (1.0 - base) * (1.0 - blend)
)
elif mode == "color_dodge":
return torch.clamp(base / (1.0 - blend + 1e-7), 0.0, 1.0)
elif mode == "color_burn":
return torch.clamp(1.0 - (1.0 - base) / (blend + 1e-7), 0.0, 1.0)
elif mode == "linear_dodge":
return torch.clamp(base + blend, 0.0, 1.0)
elif mode == "linear_burn":
return torch.clamp(base + blend - 1.0, 0.0, 1.0)
elif mode == "vivid_light":
return torch.where(
blend < 0.5,
torch.clamp(1.0 - (1.0 - base) / (2.0 * blend + 1e-7), 0.0, 1.0),
torch.clamp(base / (2.0 * (1.0 - blend) + 1e-7), 0.0, 1.0)
)
elif mode == "linear_light":
return torch.clamp(base + 2.0 * blend - 1.0, 0.0, 1.0)
elif mode == "pin_light":
return torch.where(
blend < 0.5,
torch.min(base, 2.0 * blend),
torch.max(base, 2.0 * (blend - 0.5))
)
elif mode == "hard_mix":
vl = self._blend(base, blend, "vivid_light")
return torch.where(vl < 0.5, torch.zeros_like(vl), torch.ones_like(vl))
elif mode == "difference":
return torch.abs(base - blend)
elif mode == "exclusion":
return base + blend - 2.0 * base * blend
elif mode == "subtract":
return torch.clamp(base - blend, 0.0, 1.0)
elif mode == "divide":
return torch.clamp(base / (blend + 1e-7), 0.0, 1.0)
elif mode == "lighter":
return torch.max(base, blend)
elif mode == "darker":
return torch.min(base, blend)
elif mode == "lighten":
return torch.max(base, blend)
elif mode == "darken":
return torch.min(base, blend)
# HSL blend modes
elif mode in ["hue", "saturation", "color", "luminosity"]:
return self._blend_hsl(base, blend, mode)
return blend
def _blend_hsl(self, base, blend, mode):
"""HSL-based blend modes"""
base_hsv = self._rgb_to_hsv(base)
blend_hsv = self._rgb_to_hsv(blend)
if mode == "hue":
result_hsv = torch.cat([
blend_hsv[:, :, :, 0:1],
base_hsv[:, :, :, 1:2],
base_hsv[:, :, :, 2:3]
], dim=3)
elif mode == "saturation":
result_hsv = torch.cat([
base_hsv[:, :, :, 0:1],
blend_hsv[:, :, :, 1:2],
base_hsv[:, :, :, 2:3]
], dim=3)
elif mode == "color":
result_hsv = torch.cat([
blend_hsv[:, :, :, 0:1],
blend_hsv[:, :, :, 1:2],
base_hsv[:, :, :, 2:3]
], dim=3)
else: # luminosity
result_hsv = torch.cat([
base_hsv[:, :, :, 0:1],
base_hsv[:, :, :, 1:2],
blend_hsv[:, :, :, 2:3]
], dim=3)
return self._hsv_to_rgb(result_hsv)
def _rgb_to_hsv(self, rgb):
"""Convert RGB to HSV"""
r, g, b = rgb[:, :, :, 0], rgb[:, :, :, 1], rgb[:, :, :, 2]
max_c = torch.max(torch.max(r, g), b)
min_c = torch.min(torch.min(r, g), b)
diff = max_c - min_c
# Hue
h = torch.zeros_like(max_c)
mask = diff != 0
h = torch.where(
(max_c == r) & mask,
((g - b) / (diff + 1e-7)) % 6.0,
h
)
h = torch.where(
(max_c == g) & mask,
((b - r) / (diff + 1e-7)) + 2.0,
h
)
h = torch.where(
(max_c == b) & mask,
((r - g) / (diff + 1e-7)) + 4.0,
h
)
h = h / 6.0
# Saturation
s = torch.where(max_c != 0, diff / (max_c + 1e-7), torch.zeros_like(max_c))
# Value
v = max_c
return torch.stack([h, s, v], dim=3)
def _hsv_to_rgb(self, hsv):
"""Convert HSV to RGB"""
h, s, v = hsv[:, :, :, 0] * 6.0, hsv[:, :, :, 1], hsv[:, :, :, 2]
c = v * s
x = c * (1.0 - torch.abs(h % 2.0 - 1.0))
m = v - c
h_floor = torch.floor(h) % 6
r = torch.zeros_like(h)
g = torch.zeros_like(h)
b = torch.zeros_like(h)
mask0 = h_floor == 0
mask1 = h_floor == 1
mask2 = h_floor == 2
mask3 = h_floor == 3
mask4 = h_floor == 4
mask5 = h_floor == 5
r = torch.where(mask0, c, r)
r = torch.where(mask1, x, r)
r = torch.where(mask2 | mask3, torch.zeros_like(r), r)
r = torch.where(mask4, x, r)
r = torch.where(mask5, c, r)
g = torch.where(mask0, x, g)
g = torch.where(mask1, c, g)
g = torch.where(mask2, c, g)
g = torch.where(mask3, x, g)
g = torch.where(mask4 | mask5, torch.zeros_like(g), g)
b = torch.where(mask0 | mask1, torch.zeros_like(b), b)
b = torch.where(mask2, x, b)
b = torch.where(mask3, c, b)
b = torch.where(mask4, c, b)
b = torch.where(mask5, x, b)
r = r + m
g = g + m
b = b + m
return torch.stack([r, g, b], dim=3)
class MultiTextureBlender:
"""
Blend up to 4 textures with individual blend modes, opacity, and masks
Professional layer-based compositing
"""
@classmethod
def INPUT_TYPES(cls):
blend_modes = [
"normal", "multiply", "screen", "overlay", "soft_light", "hard_light",
"color_dodge", "color_burn", "linear_dodge", "linear_burn",
"difference", "subtract", "lighter", "darker"
]
return {
"required": {
"base": ("IMAGE",),
},
"optional": {
"layer1": ("IMAGE",),
"layer1_mode": (blend_modes, {"default": "normal"}),
"layer1_opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "display": "number"}),
"layer1_mask": ("IMAGE",),
"layer2": ("IMAGE",),
"layer2_mode": (blend_modes, {"default": "multiply"}),
"layer2_opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "display": "number"}),
"layer2_mask": ("IMAGE",),
"layer3": ("IMAGE",),
"layer3_mode": (blend_modes, {"default": "overlay"}),
"layer3_opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "display": "number"}),
"layer3_mask": ("IMAGE",),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("result",)
FUNCTION = "blend_multi"
CATEGORY = "Texture Alchemist/Blending"
def blend_multi(self, base, **kwargs):
"""Blend multiple layers"""
print(f"\n{'='*60}")
print(f"Multi-Texture Blender")
print(f"{'='*60}")
result = base.clone()
blender = BlendModeUtility()
layers_applied = 0
for i in range(1, 4):
layer_key = f"layer{i}"
mode_key = f"layer{i}_mode"
opacity_key = f"layer{i}_opacity"
mask_key = f"layer{i}_mask"
if layer_key in kwargs and kwargs[layer_key] is not None:
layer = kwargs[layer_key]
mode = kwargs.get(mode_key, "normal")
opacity = kwargs.get(opacity_key, 1.0)
mask = kwargs.get(mask_key, None)
print(f"\n Layer {i}:")
print(f" Mode: {mode}")
print(f" Opacity: {opacity:.2f}")
print(f" Masked: {'Yes' if mask is not None else 'No'}")
result = blender.apply_blend(result, layer, mode, opacity, mask)[0]
layers_applied += 1
print(f"\n✓ Blended {layers_applied} layers")
print(f"{'='*60}\n")
return (result,)
# Node registration
NODE_CLASS_MAPPINGS = {
"BlendModeUtility": BlendModeUtility,
"MultiTextureBlender": MultiTextureBlender,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"BlendModeUtility": "Blend Mode Utility",
"MultiTextureBlender": "Multi-Texture Blender",
}