Posterising an image

Goal: to reduce an imported image to $k$ colours: colour quantisation, and write out as an array of classes corresponding to the $k$ colours, and an accompanying colour palette that reflects the original image.

Dependencies

We'll be using the Python Imaging Library, or rather the forked, updated, but backwards-compatible version Pillow

Imports

Use matplotlib/pylab inline with static images:


In [1]:
%pylab inline


Populating the interactive namespace from numpy and matplotlib

Import Pillow to work with images, and use the iPython display() function.


In [2]:
from PIL import Image

from IPython.display import Image as show_image

Displaying PIL images inline

iPython doesn't currently handle PIL images directly, so we use a convenience function, adapted slightly from http://nbviewer.ipython.org/gist/deeplook/5162445:


In [3]:
from io import BytesIO
from IPython.core import display

def display_pil_image(im):
   """Displayhook function for PIL Images, rendered as PNG."""

   b = BytesIO()
   im.save(b, format='png')
   data = b.getvalue()

   ip_img = display.Image(data=data, format='png', embed=True)
   return ip_img._repr_png_()

# register display func with PNG formatter:
png_formatter = get_ipython().display_formatter.formatters['image/png']
dpi = png_formatter.for_type(Image.Image, display_pil_image)

Loading the image

We'll be loading the image molecule.jpg from the data subdirectory. For convenience, we'll assign it into a variable here, along with the number of colours $k$ we want to posterise into:


In [4]:
image_filename = 'data/molecule.jpg'  # Input image filename
k = 4  # Number of colours for posterising

We can preview it directly with iPython:


In [5]:
show_image(filename=image_filename)


Out[5]:

then load it with PIL:


In [6]:
img = Image.open(image_filename)
img


Out[6]:

Posterising the image

PIL has an incompletely method for posterising images, the .quantize() method.


In [7]:
help(img.quantize)


Help on method quantize in module PIL.Image:

quantize(self, colors=256, method=None, kmeans=0, palette=None) method of PIL.JpegImagePlugin.JpegImageFile instance
    Convert the image to 'P' mode with the specified number
    of colors.
    
    :param colors: The desired number of colors, <= 256
    :param method: 0 = median cut
                   1 = maximum coverage
                   2 = fast octree
    :param kmeans: Integer
    :param palette: Quantize to the :py:class:`PIL.ImagingPalette` palette.
    :returns: A new image

We can use this to generate a new, quantized image with $2 < k \leq 256$ colours. The maximum coverage method seems to give a pleasing result here, but we'd need to use fast octree for a .png with transparency. Using .quantize() as we are here will give us a palettised image: the $x$,$y$ image co-ordinates contain values that refer to rows in a lookup table of colours (the palette).

We could use the .convert() method so that we keep the 'RGB' mode, rather than generating a palettised image but, as we might want to consider the colour indices generated as 'classes', rather than colours, we keep the 'P' palette mode, for now.


In [8]:
img_poster = img.quantize(colors=k, method=1)
img_poster


Out[8]:

Convert image to numpy array

Conversion of a PIL Image object to a numpy array is a fairly simple operation, using PIL's array() function.


In [9]:
img_array = array(img_poster)

Properties of the array can be inspected like any other numpy array:


In [10]:
img_array.size, img_array.shape, img_array.min(), img_array.max()


Out[10]:
(1024000, (800, 1280), 0, 3)

The image is a 2D array, with 1024000 elements. These are arranged in 800 rows, and 1280 columns, with minimum value 0 and maximum value 3.

This reflects that we have an 800x1200 image, posterised into four colours (indices 0 to 3).

Using the .getcolors() method, we can see how many pixels correspond to each colour (rather than an account of the colours present in the image, as might otherwise be expected):


In [11]:
img_poster.getcolors()


Out[11]:
[(55730, 0), (389996, 1), (70366, 2), (507908, 3)]

And using the .getpalette() method, we can obtain the four RGB tuple values from the image. For a $k$-colour palette, these are the first $3k$ values returned by the .getpalette() method:


In [12]:
palette = img_poster.getpalette()[:3*k]
print(palette)


[0, 0, 0, 255, 255, 255, 192, 114, 75, 140, 167, 212]

This shows that we have the colour indexing:

{0: (0, 0, 0),  # black
 1: (255, 255, 255),  #white
 2: (192, 114, 75),  # red
 3: (140, 167, 212)  # blue
 }

At this point then, we have values in four classes ${0, 1, 2, 3}$, and a well-distinguished colour associated with each class, for any datapoint in $(x, y)$ where $0 < x \leq 1280$ and $0 < y \leq 800$.

Saving image, array and colour output

The posterised image is easy to save, though we have to remember that 'P' (palette) images can't be saved in all raster formats, and '.convert()' must be used:


In [13]:
img_poster.convert('RGB').save('data/img_poster.jpg')

The numpy array can be saved separately. We do this below, in tab-separated plain text format:


In [14]:
savetxt('data/poster_array.tab', img_array, delimiter='\t')

There's no standard way to save our colour palette corresponding to the array, so we write a function to do this:


In [15]:
def save_palette(filename, palette, k):
    """Save the first k colours from the passed RGB colour palette.
    
    Writes the first k RGB colours from the ImagePalette colour
    palette to the named file. The output format is tab-separated
    RGB integers, with each line as 
    
    R\tG\t\B
    """
    with open(filename, 'w') as outfh:
        for idx in range(k):
            outfh.write('\t'.join([str(val) for val in 
                                   palette[idx * 3:idx * 3 + 3]]) + '\n')

In [16]:
save_palette('data/poster_palette.tab', palette, k)

Recolouring the palette

The Image class has a putpalette() method ([http://pillow.readthedocs.org/en/latest/reference/Image.html]) that accepts a complete (768 member) list of RGB integer values, or a shorter list, which it then pads with zeros.


In [26]:
img_poster.putpalette([255, 255, 255, 192, 114, 75, 140, 167, 212, 0, 0, 0])

In [27]:
img_poster


Out[27]:

In [ ]: