Author: Alban Fichet alban.fichet@gmx.fr
License: BSD 3-Clause License https://opensource.org/licenses/BSD-3-Clause
Last modified: Nov. 1st 2021
This code is for educational purpose, not meant to be efficient or clever.

Alpha blending is an important concept in Computer Graphics. It is the foundation of compositing techniques: it allows to merge different images together. You’re seeing it when you’re adding layers in GIMP or Photoshop, when you’re making a cross dissolve between two videos or frames…

But there is much more effects and operations that can happen when compositing layers. Let’s see some of it.

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
from PIL import Image
import os

plt.rcParams['figure.figsize'] = (20.0, 10.0)
plt.rcParams['figure.facecolor'] = 'white'

output_dir = 'output'

if not os.path.exists(output_dir):
    os.mkdir(output_dir)

Note on gamma correction

We are manipulating 8 bit integer images in this notebook. So, a gamma correction is applied on color channels. Take extra care with gamma correction. Every operation on color channel must be applied on linear color, not on gamma corrected colors.

On the other hand, the alpha channel is stored linearly, between 0 and 255. It has to be rescaled to 0 to 1 but no gamma correction is applied on alpha channel itself.

This extra care that must be taken on alpha channel is repeated all along this notebook. That may seem pedantic but trust me, it is too easy to overlook and end up with messed up colors ;-). So, excuse the redundancy.

For the sake of simplicity, we use a gamma of 2.2:

\[C_\text{gamma} = C_\text{linear}^{\frac{1}{2.2}}\]

And in reverse:

\[C_\text{linear} = C_\text{gamma}^{2.2}\]

It is a good approximation of sRGB gamma but not the exact one. The exact sRGB gamma is:

\[C_\text{sRGB} = \begin{cases} 12.92C_\text{linear}, & C_\text{linear} \le 0.0031308 \\[5mu] 1.055C_\text{linear}^{1/2.4}-0.055, & C_\text{linear} > 0.0031308 \end{cases}\]

And in reverse:

\[C_\mathrm{linear}= \begin{cases}\dfrac{C_\mathrm{sRGB}}{12.92}, & C_\mathrm{sRGB}\le0.04045 \\[5mu] \left(\dfrac{C_\mathrm{sRGB}+0.055}{1.055}\right)^{\!2.4}, & C_\mathrm{sRGB}>0.04045 \end{cases}\]

see: https://en.wikipedia.org/wiki/SRGB

Premultiplied vs. straight alpha

Two representations exist for images with an alpha channel:

  • straight alpha: each color values remains at full intensity while alpha channel is used to modulate these channels: $[R, G, B, \alpha]$
  • premultiplied alpha: each color values are stored multiplied with the alpha value: $[R \cdot \alpha, G \cdot \alpha, B \cdot \alpha, \alpha]$

To convert straight alpha to premultiplied alpha, you have to multiply the alpha channel to each color component. Note that if the color components are gamma corrected, the gamma correction must be inverted to get linear values and then reapplied. Gamma correction is a recurring thing to keep in mind when operating on color channels!

def straight_to_premul(im):
    px = np.asarray(im)
    alpha = px[:, :, 3] / 255
        
    for i in range(3):
        px[:, :, i] = 255 * pow(pow(px[:, :, i] / 255, 2.2) * alpha, 1/2.2)
    
    return Image.fromarray(px.astype(np.uint8))

In reverse, to convert a premultiplied alpha image to a straight alpha image, the alpha channel must be divided. A special care must be taken in order to avoid division by 0.

def premul_to_straight(im):
    px = np.asarray(im)
    alpha = px[:, :, 3] / 255
    
    # I'm sure there is a more pythonic way to do that...
    alpha = np.ravel(alpha)
    px_r = np.ravel(px[:, :, 0])
    px_g = np.ravel(px[:, :, 1])
    px_b = np.ravel(px[:, :, 2])
    
    px_r[alpha >= 1/255] = 255 * pow(pow(px_r[alpha >= 1/255] / 255, 2.2) / alpha[alpha >= 1/255], 1/2.2)
    px_g[alpha >= 1/255] = 255 * pow(pow(px_g[alpha >= 1/255] / 255, 2.2) / alpha[alpha >= 1/255], 1/2.2)
    px_b[alpha >= 1/255] = 255 * pow(pow(px_b[alpha >= 1/255] / 255, 2.2) / alpha[alpha >= 1/255], 1/2.2)
    
    px_r[alpha < 1/255] = 0    
    px_g[alpha < 1/255] = 0    
    px_b[alpha < 1/255] = 0    
    
    px_r.resize((im.height, im.width))
    px_g.resize((im.height, im.width))
    px_b.resize((im.height, im.width))

    px = np.stack((px_r, px_g, px_b, px[:, :, 3]), axis=-1) 
        
    return Image.fromarray(px.astype(np.uint8))

Using premultiplied alpha eases computations and is the convention used in this notebook. To ensure using the same formulation, we define a function to transform a premultiplied alpha image to a non-alpha image. Then, we’re sure that matplotlib properly displays the image as intended. In this step we also add a checkerboard pattern in the background.

When you’re dealing with an image containing an alpha channel, you have to know which representation is used before applying any alpha blending operation. The compositing formula depend on which representation is used.

# Generates a checkerboard pattern, usefull for showing background on 
# transparent images
def gen_checkerboard(width, height, value, checker_size):
    checkerboard = np.ones((height, width, 3)) * 255

    cherckerboard_sz = 16

    for i in np.r_[:checkerboard.shape[0]:checker_size]:
        for j in np.r_[:checkerboard.shape[1]:checker_size]:
            if (i/checker_size + j/checker_size) % 2 == 0:
                checkerboard[i:i+checker_size, j:j+checker_size, :] = value

    return Image.fromarray(checkerboard.astype(np.uint8))


# To ensure we work with premultiplied alpha, converts the RGBA to RGB
def get_background_premul(im):
    background = np.asarray(gen_checkerboard(im.width, im.height, 235, 16))
    image      = np.asarray(im)
    
    px_out = np.zeros((im.height, im.width, 3))
    
    background_lin = pow(background / 255, 2.2)
    colors_in_lin  = pow(image[:, :, 0:3] / 255, 2.2)
    alpha_in       = image[:, :, 3] / 255
    
    for i in range(3):
        px_out[:, :, i] = np.clip(colors_in_lin[:, :, i] + (1 - alpha_in) * background_lin[:, :, i], 0, 1)
    
    # Apply gamma mapping back
    px_out[:, :, 0:3] = 255 * pow(px_out, 1/2.2)
    
    return Image.fromarray(px_out.astype(np.uint8))

Create sample images

We create two groups of two images to show operators:

  • one group with diagonal stripes of different color, A and B
  • one group with two geometric shapes, C and D
width, height = 300, 300

# Create first set, diagonal stripes
px_a = np.zeros((height, width, 4))
px_b = np.zeros((height, width, 4))

f = 0.1

for y in range(height):
    for x in range(width):
        alpha_a = (1 + np.sin(f * (y - x) / 2)) / 2.
        
        px_a[y, x, 3] = alpha_a * 255
        px_a[y, x, 0] = 255 * pow(alpha_a, 1/2.2)
        px_a[y, x, 1] = 0
        px_a[y, x, 2] = 128 * pow(alpha_a, 1/2.2)
        
        alpha_b = (1 + np.sin(f * (y + x) / 2)) / 2.

        px_b[y, x, 3] = alpha_b * 255
        px_b[y, x, 0] = 0
        px_b[y, x, 1] = 255 * pow(alpha_b, 1/2.2)
        px_b[y, x, 2] = 128 * pow(alpha_b, 1/2.2)

im_a = Image.fromarray(px_a.astype(np.uint8))
im_b = Image.fromarray(px_b.astype(np.uint8))


# Create second set, rectangle and circle

# Rectangle
px_c = np.zeros((height, width, 4))

alpha = 0.7

px_c[height//3:height-10, 10:width - width//3, 0] = 255 * pow(alpha, 1/2.2)
px_c[height//3:height-10, 10:width - width//3, 1] = 0
px_c[height//3:height-10, 10:width - width//3, 2] = 128 * pow(alpha, 1/2.2)
px_c[height//3:height-10, 10:width - width//3, 3] = 255 * alpha

im_c = Image.fromarray(px_c.astype(np.uint8))

# Circle
px_d = np.zeros((height, width, 4))

c = (width - width//3, height//3)
r = 70

for y in range(height):
    for x in range(width):
        dist = (x - c[0])**2 + (y - c[1])**2
        if np.sqrt(dist) < r:
            alpha = 0.7
        else:
            alpha = 0
        
        px_d[y, x, 0] = 0
        px_d[y, x, 1] = 255 * pow(alpha, 1/2.2)
        px_d[y, x, 2] = 128 * pow(alpha, 1/2.2)
        px_d[y, x, 3] = 255 * alpha

im_d = Image.fromarray(px_d.astype(np.uint8))

# Save everything
get_background_premul(im_a).save(os.path.join(output_dir, 'A.png'))
get_background_premul(im_b).save(os.path.join(output_dir, 'B.png'))
get_background_premul(im_c).save(os.path.join(output_dir, 'C.png'))
get_background_premul(im_d).save(os.path.join(output_dir, 'D.png'))
plt.subplot(141)
plt.axis('off')
plt.title('A')
plt.imshow(get_background_premul(im_a))

plt.subplot(142)
plt.axis('off')
plt.title('B')
plt.imshow(get_background_premul(im_b))

plt.subplot(143)
plt.axis('off')
plt.title('C')
plt.imshow(get_background_premul(im_c))

plt.subplot(144)
plt.axis('off')
plt.title('D')
plt.imshow(get_background_premul(im_d))

plt.show()

png

Two pixels operations

These operators work on two input images and compose them together.

A over B operation

The operation A over B represents an operation where the image A covers the image B. This is probably the effect you want most of the time.

The resulting alpha channel is computed as follows:

\[\alpha_{out} = \alpha_a + \alpha_b \cdot (1 - \alpha_a)\]

And for premultiplied alpha, the color channels are computed as follows:

\[C_{out} = C_a + C_b \cdot (1 - \alpha_a)\]

Be careful with gamma correction! When the image is 8 bits / channel integer, it is more likely stored with gamma mapping. This has to be reversed prior to any operation on color channels:

\[C_{out}' = \mathrm{gamma}\left(\mathrm{gamma}^{-1}(C_a) + \mathrm{gamma}^{-1}(C_b) \cdot (1 - \alpha_a)\right)\]
def over_premul(im_a, im_b):
    px_a = np.asarray(im_a)
    px_b = np.asarray(im_b)
    
    px_c = np.zeros((im_a.height, im_a.width, 4))
    
    alpha_a = px_a[:, :, 3] / 255
    alpha_b = px_b[:, :, 3] / 255
    alpha_c = alpha_a + alpha_b * (1 - alpha_a)
    
    px_c[:, :, 3] = 255 * alpha_c
    
    for i in range(3):
        # Color channels must be linear to perform alpha compositing
        c_a_lin = pow(px_a[:, :, i]/255, 2.2) 
        c_b_lin = pow(px_b[:, :, i]/255, 2.2)
        
        # a_top_b premultiplied alpha
        c_out_lin = c_a_lin + c_b_lin * (1 - alpha_a)
        
        px_c[:, :, i] = 255 * pow(c_out_lin, 1/2.2)

    return Image.fromarray(px_c.astype(np.uint8))
im_a_over_b = over_premul(im_a, im_b)
im_c_over_d = over_premul(im_c, im_d)

get_background_premul(im_a_over_b).save(os.path.join(output_dir, 'A_over_B.png'))
get_background_premul(im_c_over_d).save(os.path.join(output_dir, 'C_over_D.png'))
plt.subplot(231)
plt.axis('off')
plt.title('A')
plt.imshow(get_background_premul(im_a))

plt.subplot(232)
plt.axis('off')
plt.title('B')
plt.imshow(get_background_premul(im_b))

plt.subplot(233)
plt.axis('off')
plt.title('A over B')
plt.imshow(get_background_premul(im_a_over_b))


plt.subplot(234)
plt.axis('off')
plt.title('C')
plt.imshow(get_background_premul(im_c))

plt.subplot(235)
plt.axis('off')
plt.title('D')
plt.imshow(get_background_premul(im_d))

plt.subplot(236)
plt.axis('off')
plt.title('C over D')
plt.imshow(get_background_premul(im_c_over_d))


plt.show()

png

We can define an operation with the coefficients $F_a$ and $F_b$ modulating color and alpha channels. For alpha channel, we get:

\[\alpha_{out} = F_a \cdot \alpha_a + F_b \cdot \alpha_b\]

and for linear color channels:

\[C_{out} = F_a \cdot C_a + F_b \cdot C_b\]

Gamma mapping: in case we’re dealing with gamma corrected color channels, the gamma mapping must be reversed prior to the transformation on color channels applied back after the transformation:

\[C'_{out} = \mathrm{gamma}\left(F_a \cdot \mathrm{gamma}^{-1}(C'_a) + F_b \cdot \mathrm{gamma}^{-1}(C'_b)\right)\]

Gamma mapping is not applied to alpha channel itself. The transformation remains as:

\[\alpha_{out} = F_a \cdot \alpha_a + F_b \cdot \alpha_b\]
def oper(im_a, im_b, f_a, f_b):
    px_a = np.asarray(im_a)
    px_b = np.asarray(im_b)
    
    px_c = np.zeros((im_a.height, im_a.width, 4))
    
    alpha_a = px_a[:, :, 3] / 255
    alpha_b = px_b[:, :, 3] / 255
    
    alpha_c = f_a * alpha_a + f_b * alpha_b
    
    px_c[:, :, 3] = alpha_c
    
    for i in range(3):
        # Color channels must be linear to perform alpha compositing
        c_a_lin = pow(px_a[:, :, i]/255, 2.2) 
        c_b_lin = pow(px_b[:, :, i]/255, 2.2)
        
        c_out_lin = f_a * c_a_lin + f_b * c_b_lin
        
        px_c[:, :, i] = pow(c_out_lin, 1/2.2)

    px_c = 255 * np.clip(px_c, 0, 1)
    
    return Image.fromarray(px_c.astype(np.uint8))

For A over B, $F_a$ and $F_b$ are defined as:

\[\begin{align} F_a &= 1 \\ F_b &= 1 - \alpha_a \end{align}\]
def over_coefs(im_a, im_b):
    alpha_a = np.asarray(im_a)[:, :, 3]

    f_a = np.ones(alpha_a.shape)
    f_b = 1 - alpha_a/255

    return f_a, f_b


def over_premul(im_a, im_b):
    f_a, f_b = over_coefs(im_a, im_b)
    return oper(im_a, im_b, f_a, f_b)

A in B operation

This operation takes the alpha channel of B and applies it to A:

\[\begin{align} F_a &= \alpha_b \\ F_b &= 0 \end{align}\]
def in_coefs(im_a, im_b):
    alpha_b = np.asarray(im_b)[:, :, 3]

    f_a = alpha_b/255
    f_b = np.zeros(alpha_b.shape)

    return f_a, f_b


def in_premul(im_a, im_b):
    f_a, f_b = in_coefs(im_a, im_b)
    return oper(im_a, im_b, f_a, f_b)
im_a_in_b = in_premul(im_a, im_b)
im_c_in_d = in_premul(im_c, im_d)

get_background_premul(im_a_in_b).save(os.path.join(output_dir, 'A_in_B.png'))
get_background_premul(im_c_in_d).save(os.path.join(output_dir, 'C_in_D.png'))
plt.subplot(231)
plt.axis('off')
plt.title('A')
plt.imshow(get_background_premul(im_a))

plt.subplot(232)
plt.axis('off')
plt.title('B')
plt.imshow(get_background_premul(im_b))

plt.subplot(233)
plt.axis('off')
plt.title('A in B')
plt.imshow(get_background_premul(im_a_in_b))


plt.subplot(234)
plt.axis('off')
plt.title('C')
plt.imshow(get_background_premul(im_c))

plt.subplot(235)
plt.axis('off')
plt.title('D')
plt.imshow(get_background_premul(im_d))

plt.subplot(236)
plt.axis('off')
plt.title('C in D')
plt.imshow(get_background_premul(im_c_in_d))


plt.show()

png

A out B

This operation removes channels of B from A:

\[\begin{align} F_a &= 1-\alpha_b \\ F_b &= 0 \end{align}\]
def out_coefs(im_a, im_b):
    alpha_b = np.asarray(im_b)[:, :, 3]

    f_a = 1 - alpha_b/255
    f_b = np.zeros(alpha_b.shape)

    return f_a, f_b


def out_premul(im_a, im_b):
    f_a, f_b = out_coefs(im_a, im_b)
    return oper(im_a, im_b, f_a, f_b)
im_a_out_b = out_premul(im_a, im_b)
im_c_out_d = out_premul(im_c, im_d)

get_background_premul(im_a_out_b).save(os.path.join(output_dir, 'A_out_B.png'))
get_background_premul(im_c_out_d).save(os.path.join(output_dir, 'C_out_D.png'))
plt.subplot(231)
plt.axis('off')
plt.title('A')
plt.imshow(get_background_premul(im_a))

plt.subplot(232)
plt.axis('off')
plt.title('B')
plt.imshow(get_background_premul(im_b))


plt.subplot(233)
plt.axis('off')
plt.title('A out B')
plt.imshow(get_background_premul(im_a_out_b))

plt.subplot(234)
plt.axis('off')
plt.title('C')
plt.imshow(get_background_premul(im_c))

plt.subplot(235)
plt.axis('off')
plt.title('D')
plt.imshow(get_background_premul(im_d))

plt.subplot(236)
plt.axis('off')
plt.title('C out D')
plt.imshow(get_background_premul(im_c_out_d))


plt.show()

png

A atop B

This operation mixes A with B where B is not transparent.

\[\begin{align} F_a &= \alpha_b \\ F_b &= (1 - \alpha_a) \end{align}\]
def atop_coefs(im_a, im_b):
    alpha_a = np.asarray(im_a)[:, :, 3]
    alpha_b = np.asarray(im_b)[:, :, 3]

    f_a = alpha_b/255
    f_b = 1 - alpha_a/255

    return f_a, f_b


def atop_premul(im_a, im_b):
    f_a, f_b = atop_coefs(im_a, im_b)
    return oper(im_a, im_b, f_a, f_b)
im_a_atop_b = atop_premul(im_a, im_b)
im_c_atop_d = atop_premul(im_c, im_d)

get_background_premul(im_a_atop_b).save(os.path.join(output_dir, 'A_atop_B.png'))
get_background_premul(im_c_atop_d).save(os.path.join(output_dir, 'C_atop_D.png'))
plt.subplot(231)
plt.axis('off')
plt.title('A')
plt.imshow(get_background_premul(im_a))

plt.subplot(232)
plt.axis('off')
plt.title('B')
plt.imshow(get_background_premul(im_b))

plt.subplot(233)
plt.axis('off')
plt.title('A atop B')
plt.imshow(get_background_premul(im_a_atop_b))


plt.subplot(234)
plt.axis('off')
plt.title('C')
plt.imshow(get_background_premul(im_c))

plt.subplot(235)
plt.axis('off')
plt.title('D')
plt.imshow(get_background_premul(im_d))

plt.subplot(236)
plt.axis('off')
plt.title('C atop D')
plt.imshow(get_background_premul(im_c_atop_d))


plt.show()

png

A xor B

This operation takes inverse of transparency on B for A and inverse of transparency of A on B.

\[\begin{align} F_a &= 1 - \alpha_b \\ F_b &= 1 - \alpha_a \end{align}\]
def xor_coefs(im_a, im_b):
    alpha_a = np.asarray(im_a)[:, :, 3]
    alpha_b = np.asarray(im_b)[:, :, 3]

    f_a = 1 - alpha_b/255
    f_b = 1 - alpha_a/255

    return f_a, f_b


def xor_premul(im_a, im_b):
    f_a, f_b = xor_coefs(im_a, im_b)
    return oper(im_a, im_b, f_a, f_b)
im_a_xor_b = xor_premul(im_a, im_b)
im_c_xor_d = xor_premul(im_c, im_d)

get_background_premul(im_a_xor_b).save(os.path.join(output_dir, 'A_xor_B.png'))
get_background_premul(im_c_xor_d).save(os.path.join(output_dir, 'C_xor_D.png'))
plt.subplot(231)
plt.axis('off')
plt.title('A')
plt.imshow(get_background_premul(im_a))

plt.subplot(232)
plt.axis('off')
plt.title('B')
plt.imshow(get_background_premul(im_b))

plt.subplot(233)
plt.axis('off')
plt.title('A xor B')
plt.imshow(get_background_premul(im_a_xor_b))


plt.subplot(234)
plt.axis('off')
plt.title('C')
plt.imshow(get_background_premul(im_c))

plt.subplot(235)
plt.axis('off')
plt.title('D')
plt.imshow(get_background_premul(im_d))

plt.subplot(236)
plt.axis('off')
plt.title('C xor D')
plt.imshow(get_background_premul(im_c_xor_d))


plt.show()

png

Summary

Operator (a, b) $F_a$ $F_b$
A over B $1$ $1 - \alpha_a$
A in B $\alpha_b$ $0$
A out B $1-\alpha_b$ $0$
A atop B $\alpha_b$ $1 - \alpha_a$
A xor B $1 - \alpha_b$ $1 - \alpha_a$

Resulting alpha channel:

\[\alpha_{out} = F_a \cdot \alpha_a + F_b \cdot \alpha_b\]

Resulting linear color channels:

\[C_{out} = F_a \cdot C_a + F_b \cdot C_b\]

Gamma mapping: in case we’re dealing with gamma corrected color channels, the gamma mapping must be reversed prior to the transformation on color channels applied back after the transformation:

\[C'_{out} = \mathrm{gamma}\left(F_a \cdot \mathrm{gamma}^{-1}(C'_a) + F_b \cdot \mathrm{gamma}^{-1}(C'_b)\right)\]
fig = plt.figure(1)

gridspec.GridSpec(4,6)

# Show starting images A & B

plt.subplot2grid((4,6), (0,0))
plt.axis('off')
plt.title('A')
plt.imshow(get_background_premul(im_a))

plt.subplot2grid((4,6), (1,0))
plt.axis('off')
plt.title('B')
plt.imshow(get_background_premul(im_b))

# Show operations for A & B

plt.subplot2grid((4,6), (0,1), colspan=1, rowspan=2)
plt.axis('off')
plt.title('A over B')
plt.imshow(get_background_premul(im_a_over_b))

plt.subplot2grid((4,6), (0,2), colspan=1, rowspan=2)
plt.axis('off')
plt.title('A in B')
plt.imshow(get_background_premul(im_a_in_b))

plt.subplot2grid((4,6), (0,3), colspan=1, rowspan=2)
plt.axis('off')
plt.title('A out B')
plt.imshow(get_background_premul(im_a_out_b))

plt.subplot2grid((4,6), (0,4), colspan=1, rowspan=2)
plt.axis('off')
plt.title('A atop B')
plt.imshow(get_background_premul(im_a_atop_b))

plt.subplot2grid((4,6), (0,5), colspan=1, rowspan=2)
plt.axis('off')
plt.title('A xor B')
plt.imshow(get_background_premul(im_a_xor_b))


# Show starting images C & D

plt.subplot2grid((4,6), (2,0))
plt.axis('off')
plt.title('C')
plt.imshow(get_background_premul(im_c))

plt.subplot2grid((4,6), (3,0))
plt.axis('off')
plt.title('D')
plt.imshow(get_background_premul(im_d))

# Show operations for C & D

plt.subplot2grid((4,6), (2,1), colspan=1, rowspan=2)
plt.axis('off')
plt.title('C over D')
plt.imshow(get_background_premul(im_c_over_d))

plt.subplot2grid((4,6), (2,2), colspan=1, rowspan=2)
plt.axis('off')
plt.title('C in D')
plt.imshow(get_background_premul(im_c_in_d))

plt.subplot2grid((4,6), (2,3), colspan=1, rowspan=2)
plt.axis('off')
plt.title('C out D')
plt.imshow(get_background_premul(im_c_out_d))

plt.subplot2grid((4,6), (2,4), colspan=1, rowspan=2)
plt.axis('off')
plt.title('C atop D')
plt.imshow(get_background_premul(im_c_atop_d))

plt.subplot2grid((4,6), (2,5), colspan=1, rowspan=2)
plt.axis('off')
plt.title('C xor D')
plt.imshow(get_background_premul(im_c_xor_d))


fig.tight_layout()
plt.show()

png

Combining operations

im_a_xor_b = xor_premul(im_a, im_b)

im_compo = in_premul(im_a_xor_b, im_d)
fig = plt.figure(1)

gridspec.GridSpec(2,4)

# Show starting images A & B

plt.subplot2grid((2,4), (0,0))
plt.axis('off')
plt.title('A')
plt.imshow(get_background_premul(im_a))

plt.subplot2grid((2,4), (1,0))
plt.axis('off')
plt.title('B')
plt.imshow(get_background_premul(im_b))

# 1st step
plt.subplot2grid((2,4), (0,1), colspan=1, rowspan=2)
plt.axis('off')
plt.title('A xor B')
plt.imshow(get_background_premul(im_a_xor_b))

plt.subplot2grid((2,4), (0,2), colspan=1, rowspan=2)
plt.axis('off')
plt.title('D')
plt.imshow(get_background_premul(im_d))

# 2nd step
plt.subplot2grid((2,4), (0,3), colspan=1, rowspan=2)
plt.axis('off')
plt.title('(A xor B) in D')
plt.imshow(get_background_premul(im_compo))

plt.show()

png

One pixel operation

The following operations works on a single image.

Darken

Darken acts on color values while remaining the alpha channel untouched: $[\rho R, \rho G, \rho B, \alpha]$ with $\rho \in [0:1]$. Again, do the transformation on linear color values, take extra care with gamma corrected images!

def darken(im, coef):
    px = np.asarray(im)
    
    c_lin = pow(px[:, :, 0:3]/255, 2.2)
    
    px[:, :, 0:3] = pow(np.clip(c_lin * coef, 0, 1), 1/2.2) * 255
    
    return Image.fromarray(px.astype(np.uint8))
im_a_darken = darken(im_a, 0.2)

get_background_premul(im_a_darken).save(os.path.join(output_dir, 'A_darken.png'))
plt.subplot(1, 2, 1)
plt.axis('off')
plt.title('A')
plt.imshow(get_background_premul(im_a))

plt.subplot(1, 2, 2)
plt.axis('off')
plt.title('darken(A, 0.2)')
plt.imshow(get_background_premul(darken(im_a, .2)))

plt.show()

png

Fade

Fade multiply both color values and alpha channel: $[\delta R, \delta G, \delta B, \delta \alpha]$ with $\delta \in [0:1]$. Again, do the transformation on linear color values, take extra care with gamma corrected images!

def fade(im, coef):
    px = np.asarray(im)
    
    c_lin = pow(px[:, :, 0:3]/255, 2.2)
    
    px[:, :, 0:3] = pow(np.clip(c_lin * coef, 0, 1), 1/2.2) * 255
    px[:, :, 3] = coef * px[:, :, 3]
    
    return Image.fromarray(px.astype(np.uint8))
im_a_fade = fade(im_a, .2)

get_background_premul(im_a_fade).save(os.path.join(output_dir, 'A_fade.png'))
plt.subplot(1, 2, 1)
plt.axis('off')
plt.title('A')
plt.imshow(get_background_premul(im_a))

plt.subplot(1, 2, 2)
plt.axis('off')
plt.title('fade(A, 0.2)')
plt.imshow(get_background_premul(fade(im_a, .2)))

plt.show()

png

Opaque

Opaque modifies the alpha channel: $[R, G, B, \omega \alpha]$.

def opaque(im, coef):
    px = np.asarray(im)
        
    px[:, :, 3] = np.clip(coef * px[:, :, 3]/255, 0, 1) * 255
    
    return Image.fromarray(px.astype(np.uint8))
im_a_opaque = opaque(im_a, 2.2)

get_background_premul(im_a_opaque).save(os.path.join(output_dir, 'A_opaque.png'))
plt.subplot(1, 2, 1)
plt.axis('off')
plt.title('A')
plt.imshow(get_background_premul(im_a))

plt.subplot(1, 2, 2)
plt.axis('off')
plt.title('opaque(A, 2.2)')
plt.imshow(get_background_premul(opaque(im_a, 2.2)))

plt.show()

png

Combining different operations

Cross dissolve

A plus B

This operator adds all channels of A and B. It is not necessarily useful by itself, you probably want to handle alpha separately but used combined with fade to cross dissolve two images (see below).

\[\begin{align} F_a &= 1 \\ F_b &= 1 \end{align}\]

Combining operators

Progressive transition between A and B: \(C = \mathrm{fade}(A, t) + \mathrm{fade}(B, 1 - t)\)

Again, do the transformation on linear color values, take extra care with gamma corrected images!

def plus_coefs(im_a, im_b):
    f_a = np.ones((im_a.height, im_a.width))
    f_b = np.ones((im_b.height, im_b.width))

    return f_a, f_b


def plus_premul(im_a, im_b):
    f_a, f_b = plus_coefs(im_a, im_b)
    return oper(im_a, im_b, f_a, f_b)


def cross_disolve_premul(im_a, im_b, t):
    im_a_f = fade(im_a, t)
    im_b_f = fade(im_b, 1 - t)
    
    return plus_premul(im_a_f, im_b_f)
plt.subplot(2, 5, 1)
plt.axis('off')
plt.title('A')
plt.imshow(get_background_premul(im_a))

plt.subplot(2, 5, 2)
plt.axis('off')
plt.title('A cross disolve B 0.25')
plt.imshow(get_background_premul(cross_disolve_premul(im_a, im_b, 0.75)))

plt.subplot(2, 5, 3)
plt.axis('off')
plt.title('A cross disolve B 0.5')
plt.imshow(get_background_premul(cross_disolve_premul(im_a, im_b, 0.5)))


plt.subplot(2, 5, 4)
plt.axis('off')
plt.title('A cross disolve B 0.75')
plt.imshow(get_background_premul(cross_disolve_premul(im_a, im_b, 0.25)))

plt.subplot(2, 5, 5)
plt.axis('off')
plt.title('B')
plt.imshow(get_background_premul(im_b))


plt.subplot(2, 5, 6)
plt.axis('off')
plt.title('C')
plt.imshow(get_background_premul(im_c))

plt.subplot(2, 5, 7)
plt.axis('off')
plt.title('C cross disolve D 0.25')
plt.imshow(get_background_premul(cross_disolve_premul(im_c, im_d, 0.75)))

plt.subplot(2, 5, 8)
plt.axis('off')
plt.title('C cross disolve D 0.5')
plt.imshow(get_background_premul(cross_disolve_premul(im_c, im_d, 0.5)))

plt.subplot(2, 5, 9)
plt.axis('off')
plt.title('C cross disolve D 0.75')
plt.imshow(get_background_premul(cross_disolve_premul(im_c, im_d, 0.25)))

plt.subplot(2, 5, 10)
plt.axis('off')
plt.title('D')
plt.imshow(get_background_premul(im_d))


plt.show()

png