Source code for psd_tools.composite.blend

"""
Blend mode implementations.

This module implements Photoshop's blend modes for compositing layers. Blend modes
determine how a layer's colors interact with the layers beneath it. Each function
implements the mathematical formula for a specific blend mode.

The blend functions operate on NumPy arrays with normalized float32 values (0.0-1.0)
representing pixel color channels. They follow Adobe's PDF Blend Mode specification.

Blend mode categories:

**Normal modes:**
- ``normal``: Source replaces backdrop (no blending)

**Darken modes:**
- ``darken``: Selects darker of source and backdrop
- ``multiply``: Multiplies colors (darkens)
- ``color_burn``: Darkens backdrop to reflect source
- ``linear_burn``: Similar to multiply but more extreme
- ``darker_color``: Selects darker color (non-separable)

**Lighten modes:**
- ``lighten``: Selects lighter of source and backdrop
- ``screen``: Inverted multiply (lightens)
- ``color_dodge``: Brightens backdrop to reflect source
- ``linear_dodge``: Same as addition (Add blend mode)
- ``lighter_color``: Selects lighter color (non-separable)

**Contrast modes:**
- ``overlay``: Combination of multiply and screen
- ``soft_light``: Soft version of overlay
- ``hard_light``: Hard version of overlay
- ``vivid_light``: Combination of color dodge and burn
- ``linear_light``: Combination of linear dodge and burn
- ``pin_light``: Replaces colors based on brightness
- ``hard_mix``: Posterizes to primary colors

**Inversion modes:**
- ``difference``: Absolute difference between colors
- ``exclusion``: Similar to difference but lower contrast

**Component modes (non-separable):**
- ``hue``: Preserves luminosity and saturation, replaces hue
- ``saturation``: Preserves luminosity and hue, replaces saturation
- ``color``: Preserves luminosity, replaces hue and saturation
- ``luminosity``: Preserves hue and saturation, replaces luminosity

Implementation details:

- Separable blend modes process each color channel independently
- Non-separable modes convert to HSL color space first
- All functions expect normalized float32 arrays (0.0-1.0 range)
- Division by zero is protected with small epsilon values

Example usage::

    import numpy as np
    from psd_tools.composite.blend import multiply, screen

    # Create backdrop and source colors (normalized)
    backdrop = np.array([0.5, 0.3, 0.8], dtype=np.float32)
    source = np.array([0.7, 0.6, 0.2], dtype=np.float32)

    # Apply blend mode
    result = multiply(backdrop, source)
    # Result: [0.35, 0.18, 0.16]

The ``BLEND_FUNC`` dictionary maps :py:class:`~psd_tools.constants.BlendMode`
enums to their corresponding functions for easy lookup during compositing.
"""

import functools
import logging

import numpy as np

from psd_tools.constants import BlendMode
from psd_tools.terminology import Enum

logger = logging.getLogger(__name__)


# Separable blend functions
def normal(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray:
    return Cs


def multiply(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray:
    return Cb * Cs


def screen(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray:
    return Cb + Cs - (Cb * Cs)


def overlay(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray:
    return hard_light(Cs, Cb)


def darken(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray:
    return np.minimum(Cb, Cs)


def lighten(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray:
    return np.maximum(Cb, Cs)


def color_dodge(Cb: np.ndarray, Cs: np.ndarray, s: float = 1.0) -> np.ndarray:
    B = np.zeros_like(Cb, dtype=np.float32)
    B[Cs == 1] = 1
    B[Cb == 0] = 0
    index = (Cs != 1) & (Cb != 0)
    B[index] = np.minimum(1, Cb[index] / (s * (1 - Cs[index] + 1e-9)))
    return B


def color_burn(Cb: np.ndarray, Cs: np.ndarray, s: float = 1.0) -> np.ndarray:
    B = np.zeros_like(Cb, dtype=np.float32)
    B[Cb == 1] = 1
    index = (Cb != 1) & (Cs != 0)
    B[index] = 1 - np.minimum(1, (1 - Cb[index]) / (s * Cs[index] + 1e-9))
    return B


def linear_dodge(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray:
    return np.minimum(1, Cb + Cs)


def linear_burn(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray:
    return np.maximum(0, Cb + Cs - 1)


def hard_light(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray:
    index = Cs > 0.5
    B = multiply(Cb, 2 * Cs)
    B[index] = screen(Cb, 2 * Cs - 1)[index]
    return B


def soft_light(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray:
    index = Cs <= 0.25
    index_not = ~index
    D = np.zeros_like(Cb, dtype=np.float32)
    D[index] = ((16 * Cb[index] - 12) * Cb[index] + 4) * Cb[index]
    D[index_not] = np.sqrt(Cb[index_not])

    index = Cs <= 0.5
    index_not = ~index
    B = np.zeros_like(Cb, dtype=np.float32)
    B[index] = Cb[index] - (1 - 2 * Cs[index]) * Cb[index] * (1 - Cb[index])
    B[index_not] = Cb[index_not] + (2 * Cs[index_not] - 1) * (
        D[index_not] - Cb[index_not]
    )
    return B


[docs] def vivid_light(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray: """ Burns or dodges the colors by increasing or decreasing the contrast, depending on the blend color. If the blend color (light source) is lighter than 50% gray, the image is lightened by decreasing the contrast. If the blend color is darker than 50% gray, the image is darkened by increasing the contrast. """ Cs2 = Cs * 2 index = Cs > 0.5 B = color_burn(Cb, Cs2) D = color_dodge(Cb, Cs2 - 1) B[index] = D[index] return B
[docs] def linear_light(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray: """ Burns or dodges the colors by decreasing or increasing the brightness, depending on the blend color. If the blend color (light source) is lighter than 50% gray, the image is lightened by increasing the brightness. If the blend color is darker than 50% gray, the image is darkened by decreasing the brightness. """ index = Cs > 0.5 B = linear_burn(Cb, 2 * Cs) B[index] = linear_dodge(Cb, 2 * Cs - 1)[index] return B
[docs] def pin_light(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray: """ Replaces the colors, depending on the blend color. If the blend color (light source) is lighter than 50% gray, pixels darker than the blend color are replaced, and pixels lighter than the blend color do not change. If the blend color is darker than 50% gray, pixels lighter than the blend color are replaced, and pixels darker than the blend color do not change. This is useful for adding special effects to an image. """ index = Cs > 0.5 B = darken(Cb, 2 * Cs) B[index] = lighten(Cb, 2 * Cs - 1)[index] return B
def difference(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray: return np.abs(Cb - Cs) def exclusion(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray: return Cb + Cs - 2 * Cb * Cs def subtract(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray: return np.maximum(0, Cb - Cs)
[docs] def hard_mix(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray: """ Adds the red, green and blue channel values of the blend color to the RGB values of the base color. If the resulting sum for a channel is 255 or greater, it receives a value of 255; if less than 255, a value of 0. Therefore, all blended pixels have red, green, and blue channel values of either 0 or 255. This changes all pixels to primary additive colors (red, green, or blue), white, or black. """ B = np.zeros_like(Cb, dtype=np.float32) B[(Cb + 0.999999 * Cs) >= 1] = 1 # There seems a weird numerical issue. return B
[docs] def divide(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray: """ Looks at the color information in each channel and divides the blend color from the base color. """ B = Cb / (Cs + 1e-9) B[B > 1] = 1 return B
# Non-separable blending must be in RGB. CMYK should be first converted to RGB, # blended, then CMY components should be retrieved from RGB results. K # component is K of Cb for hue, saturation, and color blending, and K of Cs for # luminosity.
[docs] def non_separable(k: str = "s"): """Wrap non-separable blending function for CMYK handling. .. note: This implementation is still inaccurate. """ def decorator(func): @functools.wraps(func) def _blend_fn(Cb, Cs): if Cs.shape[2] == 4: K = Cs[:, :, 3:4] if k == "s" else Cb[:, :, 3:4] Cb, Cs = _cmyk2rgb(Cb), _cmyk2rgb(Cs) return np.concatenate((_rgb2cmy(func(Cb, Cs), K), K), axis=2) return func(Cb, Cs) return _blend_fn return decorator
def _cmyk2rgb(C: np.ndarray) -> np.ndarray: return np.stack([(1.0 - C[:, :, i]) * (1.0 - C[:, :, 3]) for i in range(3)], axis=2) def _rgb2cmy(C: np.ndarray, K: np.ndarray) -> np.ndarray: K = np.repeat(K, 3, axis=2) color = np.zeros((C.shape[0], C.shape[1], 3)) index = K < 1.0 color[index] = (1.0 - C[index] - K[index]) / (1.0 - K[index] + 1e-9) return color @non_separable() def hue(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray: return _set_lum(_set_sat(Cs, _sat(Cb)), _lum(Cb)) @non_separable() def saturation(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray: return _set_lum(_set_sat(Cb, _sat(Cs)), _lum(Cb)) @non_separable() def color(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray: return _set_lum(Cs, _lum(Cb)) @non_separable("s") def luminosity(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray: return _set_lum(Cb, _lum(Cs)) @non_separable() def darker_color(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray: index = np.repeat(_lum(Cs) < _lum(Cb), 3, axis=2) B = Cb.copy() B[index] = Cs[index] return B @non_separable() def lighter_color(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray: index = np.repeat(_lum(Cs) > _lum(Cb), 3, axis=2) B = Cb.copy() B[index] = Cs[index] return B def dissolve(Cb: np.ndarray, Cs: np.ndarray) -> np.ndarray: # TODO: Implement me! logger.debug("Dissolve blend is not implemented") return normal(Cb, Cs) # Helper functions from PDF reference. def _lum(C: np.ndarray) -> np.ndarray: return 0.3 * C[:, :, 0:1] + 0.59 * C[:, :, 1:2] + 0.11 * C[:, :, 2:3] def _set_lum(C: np.ndarray, L: np.ndarray) -> np.ndarray: d = L - _lum(C) return _clip_color(C + d) def _clip_color(C: np.ndarray) -> np.ndarray: L = np.repeat(_lum(C), 3, axis=2) C_min = np.repeat(np.min(C, axis=2, keepdims=True), 3, axis=2) C_max = np.repeat(np.max(C, axis=2, keepdims=True), 3, axis=2) index = C_min < 0.0 L_i = L[index] C[index] = L_i + (C[index] - L_i) * L_i / (L_i - C_min[index] + 1e-9) index = C_max > 1.0 L_i = L[index] C[index] = L_i + (C[index] - L_i) * (1 - L_i) / (C_max[index] - L_i + 1e-9) # For numerical stability. C[C < 0.0] = 0 C[C > 1] = 1 return C def _sat(C): return np.max(C, axis=2, keepdims=True) - np.min(C, axis=2, keepdims=True) def _set_sat(C: np.ndarray, s: np.ndarray) -> np.ndarray: s = np.repeat(s, 3, axis=2) C_max = np.repeat(np.max(C, axis=2, keepdims=True), 3, axis=2) C_mid = np.repeat(np.median(C, axis=2, keepdims=True), 3, axis=2) C_min = np.repeat(np.min(C, axis=2, keepdims=True), 3, axis=2) B = np.zeros_like(C, dtype=np.float32) index_diff = C_max > C_min index_mid = C == C_mid index_max = (C == C_max) & ~index_mid index_min = C == C_min index = index_mid & index_diff B[index] = ( (C_mid[index] - C_min[index]) * s[index] / (C_max[index] - C_min[index] + 1e-9) ) index = index_max & index_diff B[index] = s[index] B[~index_diff & index_mid] = 0 B[~index_diff & index_max] = 0 B[index_min] = 0 return B """Blend function table.""" BLEND_FUNC = { # Layer attributes BlendMode.NORMAL: normal, BlendMode.MULTIPLY: multiply, BlendMode.SCREEN: screen, BlendMode.OVERLAY: overlay, BlendMode.DARKEN: darken, BlendMode.LIGHTEN: lighten, BlendMode.COLOR_DODGE: color_dodge, BlendMode.COLOR_BURN: color_burn, BlendMode.LINEAR_DODGE: linear_dodge, BlendMode.LINEAR_BURN: linear_burn, BlendMode.HARD_LIGHT: hard_light, BlendMode.SOFT_LIGHT: soft_light, BlendMode.VIVID_LIGHT: vivid_light, BlendMode.LINEAR_LIGHT: linear_light, BlendMode.PIN_LIGHT: pin_light, BlendMode.HARD_MIX: hard_mix, BlendMode.DIVIDE: divide, BlendMode.DIFFERENCE: difference, BlendMode.EXCLUSION: exclusion, BlendMode.SUBTRACT: subtract, BlendMode.HUE: hue, BlendMode.SATURATION: saturation, BlendMode.COLOR: color, BlendMode.LUMINOSITY: luminosity, BlendMode.DARKER_COLOR: darker_color, BlendMode.LIGHTER_COLOR: lighter_color, BlendMode.DISSOLVE: dissolve, # Descriptor keys Enum.Normal: normal, Enum.Multiply: multiply, Enum.Screen: screen, Enum.Overlay: overlay, Enum.Darken: darken, Enum.Lighten: lighten, Enum.ColorDodge: color_dodge, Enum.ColorBurn: color_burn, b"linearDodge": linear_dodge, b"linearBurn": linear_burn, Enum.HardLight: hard_light, Enum.SoftLight: soft_light, b"vividLight": vivid_light, b"linearLight": linear_light, b"pinLight": pin_light, b"hardMix": hard_mix, b"blendDivide": divide, Enum.Difference: difference, Enum.Exclusion: exclusion, Enum.Subtract: subtract, Enum.Hue: hue, Enum.Saturation: saturation, Enum.Color: color, Enum.Luminosity: luminosity, b"darkerColor": darker_color, b"ligherColor": lighter_color, Enum.Dissolve: dissolve, }