Intro¶

In this small notebook I'm going to play around with gamma correction - especially in terms of sRGB - and then write and apply my own image filtering function to apply arbitrary kernels. This is probably going to be a little more messy to read if you're not viewing this with the .ipynb notebook file but instead reside to the PDF or HTML version (e.g. interactive elements wont work). But nonetheless there should still be interesting inside to be gained.

Identifying and Gamma-Correcting an Image¶

In [1]:
import math
import numpy as np
from PIL import Image
from ipywidgets import interact
In [2]:
im = Image.open("bouquetofflowers.png")
im
Out[2]:

The image used is called "A Hanging Bouquet of Flowers" by Abraham Mignon c. 1665-1670.

First we need to identify what color space is being used because of eventual gamma correction:

In [3]:
%%sh
magick identify bouquetofflowers.png
bouquetofflowers.png PNG 491x614 491x614+0+0 8-bit sRGB 461344B 0.002u 0:00.002

Because we are dealing with a sRGB encoded image, it would be wise to do gamma correction:

In [4]:
# values are 8 bit ints so we need to normalize them
# the 2.2 gamma is only approximate
im.point(lambda p: (p / 255) ** 2.2 * 255)
Out[4]:

To better illustrate how the gamma effects the brightness of the image here is an interactive slider:

In [5]:
@interact(gamma=(0.0, 5.0))
def update_gamma(gamma = 2.2):
    return im.point(lambda p: (p / 255) ** gamma * 255)
interactive(children=(FloatSlider(value=2.2, description='gamma', max=5.0), Output()), _dom_classes=('widget-i…

The actual gamma decoding as specified for sRGB would work like this: $$ C_{\mathrm{linear}} = \begin{cases} {\frac {C_{\mathrm{srgb}}}{12.92}}, & C_{\mathrm{srgb}} \leq 0.04045 \\ \left( {\frac {C_{\mathrm{srgb}} + 0.055}{1.055}} \right)^{2.4}, & C_{\mathrm{srgb}} \gt 0.04045 \end{cases} $$

So let's write a function that we can use for proper gamma decoding.

In [6]:
def srgb_gamma_decode(_p):
    """Decode sRGB gamma"""

    p = _p / 255 # normalize
    
    if p <= 0.04045:
        p /= 12.92
    else:
        p = ((p + 0.055) / 1.055) ** 2.4
    
    return p * 255

With this function we can gamma decode the sRGB values into linear values be just giving the function as a parameter to point().

In [7]:
out = im.point(srgb_gamma_decode)
out
Out[7]:

The result looks very similar to the 2.2 gamma decode! We may also define the encoding function which we will need later. It is defined like the following:

$$ C_{\mathrm{srgb}} = \begin{cases} 12.92 \cdot C_{\mathrm{linear}}, & C_{\mathrm{linear}} \leq 0.0031308 \\ 1.055 \cdot C_{\mathrm{linear}}^{\frac{1}{2.4}} - 0.055, & C_{\mathrm{linear}} \gt 0.0031308 \end{cases} $$
In [8]:
def srgb_gamma_encode(_p):
    """Encode sRGB gamma"""

    p = _p / 255 #normalize
    
    if p <= 0.0031308:
        p *= 12.92
    else:
        p = 1.055 * p ** (1/2.4) - 0.055
    
    return p * 255

Applying Kernels¶

After this step we could load the array into an NumPy array and do our calculations. In this example I want to apply a custom sharpening kernel $K$ to the image:

$$ K = \begin{bmatrix} 0 & -3 & 0 \\ -3 & 13 & -3 \\ 0 & -3 & 0 \end{bmatrix} $$

This is done by calculating a sum $\dddot s = \sum_i K_i \dddot p_i$ from each pixel $\dddot p_i$ in the source image times the corresponding kernel factor value $K_i$. The sum will then be the new pixel in the output image.

In [9]:
def imfilter(_i: np.ndarray, K: np.ndarray) -> np.ndarray:
    """Apply kernel K to image _i"""

    i = np.array(_i) / 255         # input (normalized)
    o = np.zeros(i.shape)          # output
    n = K.shape[0]                 # kernel diameter
    r = math.floor(n / 2)          # kernel radius
    d = (_i.shape[1], _i.shape[0]) # image dimensions

    if (K.shape[0] != K.shape[1]):
        raise ValueError(f"Kernel not square: {K.shape}")
    
    for x in range(0, d[0]):
        for y in range(0, d[1]):
            ps = np.zeros(3)
            for l in range(0, n):
                for j in range(0, n):
                    ps += i[min(max(y+j-r, 0), d[1]-1), min(max(x+l-r, 0), d[0]-1)] * K[j, l]
            o[y, x] = np.clip(ps, 0, 1) # CLIP RESULT

    return (o * 255).astype(np.uint8) # be wary of overflows

The resulting code is an implementation of an image filter function to apply np.ndarray kernels to an image. It is very slow and you should probably use the built-in filter mechanics of Pillow.

In [10]:
sharp = np.array( # [y, x] access
    [[0, -3, 0],
    [-3, 13, -3],
    [0, -3, 0]]
)
blur = np.array( # [y, x] access
    [[0, 1, 0],
    [1, 1, 1],
    [0, 1, 0]]
) * (1/5)

out2 = Image.fromarray(imfilter(np.array(out), sharp))
out2 = Image.fromarray(imfilter(np.array(out2), blur))
out2.point(srgb_gamma_encode) # gamma encode
Out[10]:

The result isn't very pretty per se, because the harsh sharpening kernel really amplifies the color banding. That's why I also added a blurring kernel to output a smoother result so the sharpening gets remedied a little.