421 lines
14 KiB
Python
421 lines
14 KiB
Python
"""
|
||
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",
|
||
}
|
||
|