More Image Manipulations in Python with Pillow

In a previous post I introduced the Pillow imaging library and demonstrated some of its core functionality. In this post I'll show a few more features including the seemingly Dark Art of getting decent black and white images from a colour photo.

In this post I will cover these topics:

  • Converting an image to black and white the wrong way!

  • Converting an image to black and white the right way

  • Improving a flat and dull B&W image by increasing its contrast

  • Splitting an image into its RGB channels, editing them, and then gluing them back together

  • Showing how to save images at various qualities

Starting to Code

Create a new folder and within it create an empty file called morepillow.py. (You can download the source code or clone/download from Github if you prefer.)

Source Code Links

ZIP File
GitHub

In this file we will write a number of functions to demonstrate various manipulations on this JPEG image of Google's London office at Central St. Giles. This image is included in the download zip and the Github repository but you might like to substitute your own.


Imports, main and image information

This is the first part of morepillow.py. This part is basically a recap of the first part of my previous post but I have included the show_image_info function again as it will come in useful in a moment. Most of the function calls are commented out so we can uncomment and run them one at a time.

morepillow.py part 1

import PIL
from PIL import Image
from PIL import ImageEnhance


def main():

    print("-----------------")
    print("| codedrome.com |")
    print("| More Pillow   |")
    print("-----------------\n")

    openfilepath = "central_st_giles.jpg"
    show_image_info(openfilepath)

    # The desaturate function is the wrong way to change an image
    # to black and white and is included here with show_image_info
    # to demonstrate that it leaves the image with a mode of RGB
    #desaturate(openfilepath, "central_st_giles_desaturated.jpg")
    #show_image_info("central_st_giles_desaturated.jpg")

    # This is the correct way to convert an image to B&W.
    # Calling show_image_info will show a mode of L
    #mode_L(openfilepath, "central_st_giles_mode_L.jpg")
    #show_image_info("central_st_giles_mode_L.jpg")

    #contrast("central_st_giles_mode_L.jpg", "central_st_giles_mode_L_increased_contrast.jpg", 2.0)

    #bands_brightness(openfilepath, "central_st_giles_bands_brightness.jpg", 1.0, 1.0, 2.0)

    #quality_demo("central_st_giles.jpg")


def show_image_info(openfilepath):

    """
    Open an image and show a few attributes
    """

    try:

        image = Image.open(openfilepath)

        print("filename:           {}".format(image.filename))
        print("size:               {}".format(image.size))
        print("width:              {}".format(image.width))
        print("height:             {}".format(image.height))
        print("format:             {}".format(image.format))
        print("format description: {}".format(image.format_description))
        print("mode:               {}\n".format(image.mode))

    except IOError as ioe:

        print(ioe)

Run the program as it is with the following command:

Running the Program

python3.7 morepillow.py

This will give us the following output. Note in particular that the mode is RGB.

Program Output

-----------------
| codedrome.com |
| MorePillow    |
-----------------

filename:           central_st_giles.jpg
size:               (600, 450)
width:              600
height:             450
format:             JPEG
format description: JPEG (ISO 10918)
mode:               RGB

Converting an Image to Black and White

I mentioned in the previous post that reducing the saturation to 0 has the effect of converting the image to black and white. The first of the following functions does that, and the second does the same job but by using the convert method with an argument of "L". (Despite extensive Googling I have been unable to find out what "L" stands for.)

morepillow.py part 2

def desaturate(openfilepath, savefilepath):

    """
    Convert an image to black and white the wrong way.
    This method still leaves the image with a colour
    depth of 24 bit RGB.
    The correct method is to use convert("L")
    """


    try:

        image = Image.open(openfilepath)

        enhancer = ImageEnhance.Color(image)

        image = enhancer.enhance(0.0)

        image.save(savefilepath)

        print("Image desaturated")

    except IOError as ioe:

        print(ioe)

    except ValueError as ve:

        print(ve)


def mode_L(openfilepath, savefilepath):

    """
    The correct way to convert an image to black and white.
    Do not use ImageEnhance.Color to reduce saturation to 0
    as that leaves the colour depth at 24 bit.
    """


    try:

        image = Image.open(openfilepath)

        image = image.convert("L")

        image.save(savefilepath)

        print("Mode changed to L")

    except IOError as ioe:

        print(ioe)

    except ValueError as ve:

        print(ve)

Uncomment desaturate and mode_L in main and run the program again. This will give us the following output.

Program Output

-----------------
| codedrome.com |
| MorePillow    |
-----------------

Image desaturated
filename:           central_st_giles_desaturated.jpg
size:               (600, 450)
width:              600
height:             450
format:             JPEG
format description: JPEG (ISO 10918)
mode:               RGB

Mode changed to L
filename:           central_st_giles_mode_L.jpg
size:               (600, 450)
width:              600
height:             450
format:             JPEG
format description: JPEG (ISO 10918)
mode:               L

If you go to the folder where you have your source code and images you'll find a couple more images have been created. The first, central_st_giles_desaturated.jpg, was created by the desaturate function and appears to be black and white but as you can see from the above output it is technically an RGB image which happens to contain only shades of grey.

The second function, mode_L, does the conversion properly and creates central_st_giles_mode_L.jpg which does actually have an 8-bit colour depth or a mode of L - this is that image.


Improving Contrast

The above image doesn't look too bad but a very common problem is that images converted from colour to black and white look rather flat and boring. To solve this we need to increase the contrast, often by quite a lot.

The following function does this, and is a more general-purpose version of the contrast function in the previous post. Instead of having the contrast amount hard-coded for demo purposes it takes a value as an argument.

morepillow.py part 3

def contrast(openfilepath, savefilepath, amount):

    """
    A general-purpose function to change the contrast
    by the specified amount and save the image.
    """


    try:

        image = Image.open(openfilepath)

        enhancer = ImageEnhance.Contrast(image)

        image = enhancer.enhance(amount)

        image.save(savefilepath)

        print("Contrast changed")

    except IOError as ioe:

        print(ioe)

    except ValueError as ve:

        print(ve)

Uncomment the call to contrast in main and run the program. It will create this image which is a lot punchier, and the clouds and sky in particular look much better.


Splitting and Editing Colour Bands

The three colour channels (or bands to use Pillow's terminology) of an RGB image can be separated, edited individually, and then put back together. Most of the time you'll want to edit the whole image but editing individual channels allows you to alter the colour balance, and the following function does that by altering the brightnesses of the red, green and blue channels by the specified amounts.

After opening the image it calls the split() method. This returns a tuple of three images but as we need to overwrite them the tuple is cast to a list.

It then uses ImageEnhance.Brightness which I introduced in the earlier post, but on each of the three colour channels. Finally we stick the channels back together into a single image using merge() and then save that image.

morepillow.py part 4

def bands_brightness(openfilepath, savefilepath, r, g, b):

    """
    Split the image into colour channels (bands),
    change the brightness of each by the specified amount,
    merge the channels and save the image.
    """


    try:

        image = Image.open(openfilepath)

        # image.split() returns a tuple so we need to convert
        # it to a list so we can overwrite the bands.

        bands = list(image.split())

        enhancer = ImageEnhance.Brightness(bands[0])
        bands[0] = enhancer.enhance(r)

        enhancer = ImageEnhance.Brightness(bands[1])
        bands[1] = enhancer.enhance(g)

        enhancer = ImageEnhance.Brightness(bands[2])
        bands[2] = enhancer.enhance(b)

        image = PIL.Image.merge("RGB", bands)

        image.save(savefilepath)

        print("Band brightnesses changed")

    except IOError as ioe:

        print(ioe)

    except ValueError as ve:

        print(ve)

In main I have called bands_brightness with values of 1.0 for red and green, but 2.0 for blue. This is an attempt to replicate those wonderful old Kodachrome images from the 1950s when skies and seas were impossibly bright and vivid. Kodak revised Kodachrome in 1962 to give more natural looking colours and the world became a more miserable place.

Uncomment the function call in main and run the program. This is the result.


Saving Images at Various Qualities

Finally, lets look at changing the quality of files while saving them. The save method has an optional argument called quality which can be any value between 1 and 100, the higher the number the better the quality.

The following function demonstrates this by saving the supplied image at 25, 50, 75 and 100 using a loop, and with the qualities as file names.

morepillow.py part 5

def quality_demo(openfilepath):

    """
    Save the specified image at several different quality levels
    for demonstration purposes.
    Quality can be any value between 1 (awful) to 100 (best).
    Anything < 50 is unlikely to be acceptable.
    """


    try:

        image = Image.open(openfilepath)

        for q in range(25, 101, 25):

            filename = str(q) + ".jpg"

            image.save(filename, quality=q)

        print("Image saved at various qualities")

    except IOError as ioe:

        print(ioe)

    except ValueError as ve:

        print(ve)

If you run it from main you'll get four new files of various sizes, the smallest and worst being this one, 25.jpg.


I tried saving the image with a quality of 1 but the result was too dreadful to inflict on anyone, and even 25 is pretty poor as you can see.

The image saved at 75 was very good and acceptable for use on a web site but in an age of huge and cheap storage and fast internet connections I don't think there is really any need to trade quality for file size. If you really need to cut down on file sizes I think most people would rather see physically smaller images in good quality than larger images of poor quality.