Write simple image filters in Python

Recently I became interested in how simple image filters work: grayscale, brightness, contrast, sepia, etc. Armoured with Google and Python, I decided to spend an evening doing something useful and figuring out the subject a little.

This article isn't a comprehensive manual. Some algorithms I got from Wikipedia or other sites and some I rewrote to Python from another languages. All of them are available on the Internet and you won’t find anything especially new here.

You can find the source code and images from the article here.

This is an image we will mock on in the article:

The source image

When working with images we use Python library PIL. We need only one class from it:

from PIL import Image

Brightness changing

Let's start from the simplest - brightness changing. If we want to increase brightness, then we should increase the value of each colour channel by needed coefficient. If we want to decrease brightness - decrease values. For all channels we should use the same coefficients. If the resulting value of the channel is greater than 255 or less than 0, then we must normalize it.

def bright(source_name, result_name, brightness):
    source = Image.open(source_name)
    result = Image.new('RGB', source.size)
    for x in range(source.size[0]):
        for y in range(source.size[1]):
            r, g, b = source.getpixel((x, y))

            red = int(r * brightness)
            red = min(255, max(0, red))

            green = int(g * brightness)
            green = min(255, max(0, green))

            blue = int(b * brightness)
            blue = min(255, max(0, blue))

            result.putpixel((x, y), (red, green, blue))
    result.save(result_name, "JPEG")

If brightness if greater than 1, the image will become brighter. If brightness is less than 1, the image will become darker. Here is how the source image will look with brightness = 0.5 and brightness = 1.5 respectively:

Brightness 0.5 Brightness 1.5

Negative

One more elementary filter is negative. For it we should invert the value of each channel. Just subtract the channel value from 255. The resulting value can't go beyond limits here, so we need no validation.

def negative(source_name, result_name):
    source = Image.open(source_name)
    result = Image.new('RGB', source.size)
    for x in range(source.size[0]):
        for y in range(source.size[1]):
            r, g, b = source.getpixel((x, y))
            result.putpixel((x, y), (255 - r, 255 - g, 255 - b))
    result.save(result_name, "JPEG")

This is the result of processing the source image by this filter:

Negative

Black and white image

An image is drawn by only two colours: black and white. So we should split all pixels into two groups: light and dark. For this we summarize values of all channels and compare the result with the threshold value. If the result if greater than this value, pixels will be drawn in black colour, otherwise - in white colour. There are several ways to calculate the threshold value. I like this one: separator = 255 / brightness / 2 * 3. Where brightness is a value, specified by a user. The higher the number, the more pixels will be painted white.

def white_black(source_name, result_name, brightness):
    source = Image.open(source_name)
    result = Image.new('RGB', source.size)
    separator = 255 / brightness / 2 * 3
    for x in range(source.size[0]):
        for y in range(source.size[1]):
            r, g, b = source.getpixel((x, y))
            total = r + g + b
            if total > separator:
                result.putpixel((x, y), (255, 255, 255))
            else:
                result.putpixel((x, y), (0, 0, 0))
    result.save(result_name, "JPEG")

The source image with brightness = 0.8 and brightness = 1.2 respectively:

Black level 0.8 Black level 1.2

Grayscale

Speaking roughly, for this filter we should paint each pixel with an average value of all its colour channels gray = (red + green + blue) /3. This formula will work but the result won’t be very good. The fact is that the human eye perceives the brightness of different colours differently. Because of this, for example, red colour will have more impact on the result than other colours. We should remember this if we want to get a pretty resulting image.

For this issue we will multiply values of all colour channels by a specific coefficient. The coefficient will be different for all channels. There are some different variants of these coefficients. In my opinion this formula is the best: gray = int(r * 0.2126 + g * 0.7152 + b * 0.0722).

def gray_scale(source_name, result_name):
    source = Image.open(source_name)
    result = Image.new('RGB', source.size)
    for x in range(source.size[0]):
        for y in range(source.size[1]):
            r, g, b = source.getpixel((x, y))
            gray = int(r * 0.2126 + g * 0.7152 + b * 0.0722)
            result.putpixel((x, y), (gray, gray, gray))
    result.save(result_name, "JPEG")

The filter result:

Grayscale

Sepia

To represent the source image in sepia we should average the value of all colour channels and displace the resulting value to the sepia colour (#704214). Same as in grayscale there are some different variants of displaced coefficients. I didn't notice any difference between them.

def sepia(source_name, result_name):
    source = Image.open(source_name)
    result = Image.new('RGB', source.size)
    for x in range(source.size[0]):
        for y in range(source.size[1]):
            r, g, b = source.getpixel((x, y))
            red = int(r * 0.393 + g * 0.769 + b * 0.189)
            green = int(r * 0.349 + g * 0.686 + b * 0.168)
            blue = int(r * 0.272 + g * 0.534 + b * 0.131)
            result.putpixel((x, y), (red, green, blue))
    result.save(result_name, "JPEG")

The source image in sepia:

Sepia

Contrast

The last filter reviewed - changing of an image contrast. This algorithm is more difficult and contains several steps.

At first we calculate an average brightness value for the full image.

avg = 0
for x in range(source.size[0]):
    for y in range(source.size[1]):
        r, g, b = source.getpixel((x, y))
        avg += r * 0.299 + g * 0.587 + b * 0.114
avg /= source.size[0] * source.size[1]

After this for each channel of each pixel we calculate the deviation from the value that we calculated in the previous step and increase/decrease it by coefficient specified by a user. This step can be optimized. Since the channels values are between 0 and 255, we can precalculate new values for all of them. And then just take the precalculated value for each channel.

palette = []
for i in range(256):
    temp = int(avg + coefficient * (i - avg))
    if temp < 0:
        temp = 0
    elif temp > 255:
        temp = 255
    palette.append(temp)

Process the image:

for x in range(source.size[0]):
    for y in range(source.size[1]):
        r, g, b = source.getpixel((x, y))
        result.putpixel((x, y), (palette[r], palette[g], palette[b]))

In the end our filter looks like this:

def contrast(source_name, result_name, coefficient):
    source = Image.open(source_name)
    result = Image.new('RGB', source.size)

    avg = 0
    for x in range(source.size[0]):
        for y in range(source.size[1]):
            r, g, b = source.getpixel((x, y))
            avg += r * 0.299 + g * 0.587 + b * 0.114
    avg /= source.size[0] * source.size[1]

    palette = []
    for i in range(256):
        temp = int(avg + coefficient * (i - avg))
        if temp < 0:
            temp = 0
        elif temp > 255:
            temp = 255
        palette.append(temp)

    for x in range(source.size[0]):
        for y in range(source.size[1]):
            r, g, b = source.getpixel((x, y))
            result.putpixel((x, y), (palette[r], palette[g], palette[b]))

    result.save(result_name, "JPEG")

And contrast image with coefficient = 2 looks like this:

Contrast

Conclusion

In this article we reviewed only the simplest image filters. I didn't set a goal to show something cool. I just wanted to know how they work and try to implement them by myself. Maybe it will be interesting for someone else.

You can read this article in Russian here.

Comments

Use Markdown

Thank you for comment!
Your message will be available after moderation.