Creating a sepia filter with python

HOME
- By Yábir García

Introduction

Last week I wanted to emulate the css effects in python on images. The filter that caught my attention was the sepia filter. I write this because I couldn't find much information about the topic on the web.

Basics

A quick search on your favourite search engine leads to this page where as mentioned in stackoverflow ([^1]) what you have to do is:

outputRed = (inputRed * .393) + (inputGreen *.769) + (inputBlue * .189)
outputGreen = (inputRed * .349) + (inputGreen *.686) + (inputBlue * .168)
outputBlue = (inputRed * .272) + (inputGreen *.534) + (inputBlue * .131)

The result is (Image by Andrea Ranalleta on Unsplash):

Coding this on python

With this information what you have to do is to ignore the alpha layer and for each pixel calculate a bunch of numbers.

You can use Pillow for this and then on your program:

from PIL import Image

def sepia(image_path:str)->Image:
    img = Image.open(image_path)
    width, height = img.size

    pixels = img.load() # create the pixel map

    for py in range(height):
     for px in range(width):
         r, g, b = img.getpixel((px, py))

         tr = int(0.393 * r + 0.769 * g + 0.189 * b)
         tg = int(0.349 * r + 0.686 * g + 0.168 * b)
         tb = int(0.272 * r + 0.534 * g + 0.131 * b)

         if tr > 255:
             tr = 255

         if tg > 255:
             tg = 255

         if tb > 255:
             tb = 255

         pixels[px, py] = (tr,tg,tb)

    return img

Here I'm assuming that the image is a jpeg, but in case of a png is similar but taking care of the alpha layer. This is easy, but very slow, so ... how can this be faster?

My next search was for applying filters to images and I discovered cv2. Scrolling fast the docs I wrote this:

import cv2
import numpy as np
from PIL import Image

def sepia_cv(image_path:str)->Image:
    """
    Optimization on the sepia filter using cv2 
    """

    image = Image.open(image_path)

    # Load the image as an array so cv knows how to work with it
    img = np.array(image)

    # Apply a transformation where we multiply each pixel rgb 
    # with the matrix for the sepia

    filt = cv2.transform( img, np.matrix([[ 0.393, 0.769, 0.189],
                                          [ 0.349, 0.686, 0.168],
                                          [ 0.272, 0.534, 0.131]                                  
    ]) )

    # Check wich entries have a value greather than 255 and set it to 255
    filt[np.where(filt>255)] = 255

    # Create an image from the array 
    return Image.fromarray(filt)

What is happening here is that the previous sums and products are no more than a linear transformation between subspaces of the reals, so we can represent this as a matrix. But loading cv2 and numpy for this is too much so let's just use numpy.

def sepia_np(image_path:str)->Image:
    """
    Optimization on the sepia filter using numpy 
    """

    image = Image.open(image_path)

    # Load the image as an array so cv knows how to work with it
    img = np.array(image)

    # Apply a transformation where we multiply each pixel 
    # rgb with the matrix transformation for the sepia

    lmap = np.matrix([[ 0.393, 0.769, 0.189],
                      [ 0.349, 0.686, 0.168],
                      [ 0.272, 0.534, 0.131]                                  
    ])

    filt = np.array([x * lmap.T for x in img] )

    # Check wich entries have a value greather than 255 and set it to 255
    filt[np.where(filt>255)] = 255

    # Create an image from the array
    return Image.fromarray(filt.astype('uint8'))

In this solution take care of ([^2])

Improving the filter

In CSS you can apply an scale to the filter. After searching a lot I found this link where it specifies how to apply the filter using this scale. The thing is that aside the linear map, you are using a movement. The transformation matrix is:

    matrix = [[ 0.393 + 0.607 * (1 - k), 0.769 - 0.769 * (1 - k), 0.189 - 0.189 * (1 - k)],
    [ 0.349 - 0.349 * (1 - k), 0.686 + 0.314 * (1 - k), 0.168 - 0.168 * (1 - k)],
    [ 0.272 - 0.349 * (1 - k), 0.534 - 0.534* (1 - k), 0.131 + 0.869 * (1 - k)]]

where k is the interval [0,1].

Here are some examples:

Measuring times

Calling python -m cProfile test.py (^3) with my Pentium G3258 on the image used in this post (2453x2453 pixels):

    ncalls  tottime  percall  cumtime  percall filename:lineno(function)

         1    0.007    0.007    0.293    0.293 test.py:34(sepia_cv)
         1    0.033    0.033    0.499    0.499 test.py:56(sepia_np)
         1    7.857    7.857   16.634   16.634 test.py:7(sepia)

This times don't count the time importing the modules used.

[^1]: How is a sepia tone created?

[^2]: Why we need uint8