visualising the nationality of Nobel Peace Prize Winners
Visualizing the nationality of Nobel Peace Prize winners over time
Visualizing the nationality of Nobel Peace Prize winners over time
Steganography is greek for ‘hidden writing‘; the act of hiding a message inside another message.
In this case, hiding an image inside another image, without it being obvious to the viewer. The example I’ll give here is only a ‘toy’ implementation, for two reasons:-
But it does illustrate how to do bitwise-manipulation of images in PIL using the ImageMath module, which is the purpose of the post.
How it works
The watermark – the image we wish to hide – is a bitonal image, with black and white pixels only. It’s then resized to be the same size as the original image.
We ‘smuggle’ the watermark inside the original by replacing the LSB (least significant bit) of each colour channel (R,G and B) in the original with the corresponding pixel in the watermark – either 1 for white, or 0 for black.
This image shows the binary arithmetic…least significant bit on the right.

Hiding the watermark image inside our image
For this, we’ll need these imports…
from PIL import Image, ImageMath
and open the two files. The watermark is scaled to match the size of the original image.
watermark=Image.open(r"c:\watermark.png") original=Image.open(r"c:\original.jpg") watermark=watermark.resize(original.size)
ImageMath only works with single channel (greyscale) images, so we need to split the two images into their three channels (Red, Green and Blue) using the split() method.
red, green, blue = original.split() wred, wgreen, wblue = watermark.split()
Now, using ImageMath. ImageMath lets you write simple expressions using values from one or more images. Here, ‘a’ and ‘b’ are bound to the values in the original and watermarked images, respectively. The convert() call is needed to prevent problems later; we need to cast the results back to a greyscale image (mode ‘L’).
red2 = ImageMath.eval("convert(a&0xFE|b&0x1,'L')", a=red, b=wred)
green2 = ImageMath.eval("convert(a&0xFE|b&0x1,'L')", a=green, b=wgreen)
blue2 = ImageMath.eval("convert(a&0xFE|b&0x1,'L')", a=blue, b=wblue)
Okay, so now we have three channels whose LSBs have been replaced with the LSB of the watermark.
But we need to combine the 3 channels back to get an RGB image ready for saving.
out = Image.merge("RGB", (red2, green2, blue2))
out.save(r"c:\merged.png")
Open the original and the processed images; can you see any difference?
Extracting the hidden image
All this is for nought if you can’t extract the hidden image afterwards.
This is simpler, as we only need to produce a black/white image from the LSB of the image. Here, I’ve only bothered with the Red channel.
stegged=Image.open(r"c:\merged.png")
red, green, blue = stegged.split()
watermark=ImageMath.eval("(a&0x1)*255",a=red) # convert to 0 or 255
watermark=watermark.convert("L")
watermark.save(r"c:\extracted-watermark.png")
If you’ve ever wondered why desaturate gives disappointing greyscale results in Photoshop or the Gimp, here’s a (slightly) scientific demo of why.
The diagram below is the sRGB gamut, viewed from above. In reality the gamut a twisted blob in 3d space, but this is a ‘plan’ – you’re looking down on it, so you’re seeing every possible RGB hue at its highest luminosity level. You’re seeing the bright sunlit version, rather than the shadowy underbelly. Darker versions of the same hue are hidden behind the pixels you see. (This was generated with a C# program I wrote a while back.)

This triangle is called the Maxwell Triangle; the primary colours (R, G and B) are the vertices of the triangle, and every hue is a weighted average of those three primaries in different proportions. The secondary colours (Cyan, Magenta, Yellow) are mixtures of two of the primaries, and appear on the edges of the triangle.
Draw an imaginary line between each primary and the opposite secondary; where the 3 lines cross, you have the White Point – this is the axis (disappearing into your screen) of neutral tones between white and black.
Look what happens if you apply Grayscale to it

Notice how Blue is darkest, Red is dark, and that White and Yellow are close together in brightness.
This is good; the greyscale algorithm takes into account human sensitivity to colour, and the influence of colour on tone. Pure yellow is lighter than pure Blue, as it is in real life.
Contrast this with the effect of applying Desaturate on the same gamut image.

Notice how white maps to white; as you’d expect. Now, the fully saturated hues (those lying on the boundary of the triangle) are all mid-grey.
Yellow is now the same tone as pure blue.
Greyscale takes into account the true tonal values of colours.
Desaturate is a simple average of the tonal values of each channel.
Desaturate? Don’t bother.
You can get a CC-NC-BY version of the gamut image at higher resolution on my Flickr stream here.