A while back I wrote some code to annotate astronomical images with Pillow, a fork of the python image library. My notes might be useful for some, so here they are!

You can find the final script and images files I used in here.

## Installation

The pip install worked out of the box on my mac (running OS X 10.10):

pip install Pillow


## The Basics

To read a file and extract some metadata using the Image class and its associated attributes:

> from PIL import Image
>im = Image.open("color_igr_ra70.602710_dec-21.724330_arcsec600_skycell0812.050.jpeg").convert("RGBA")
> im.size
(2400, 2400)

> im.mode
'RGB'

> im.format
'JPEG'

> im.width
2400

> im.height
2400

> im.palette

> im.info
{jfif': 257, 'jfif_density': (1, 1), 'jfif_unit': 0, 'jfif_version': (1, 1)}


Using the show() method you can quickly display your image in whatever image-viewer you use on you machine.

im.show()


This reveals the image I’m working on in the Preview app on my mac (as a temporary BMP image).

## Annotating Images

Pillow’s ImageDraw is the main module we’ll be using to annotate our images with lines, shapes and text. The first task I would like to perform is to add cross-hairs to my image to draw the viewer’s eye to the pin-pointed centre of the field.

from PIL import Image, ImageDraw

im = Image.open(
"color_igr_ra70.602710_dec-21.724330_arcsec600_skycell0812.050.jpeg").convert("RGBA")

#DETERMINE THE SIZE OF THE IMAGE
imWidth, imHeight = im.size

#THE CROSS HAIRS SHOULD BE 1/6 THE LENGTH OF THE SMALLEST DIMENSON
chLen = int(min(imWidth, imHeight) / 6)

#THE GAP IN THE CENTRE SHOULD BE 1/60 OF THE LENGTH OF THE SMALLEST DIMENSON
gapLen = int(min(imWidth, imHeight) / 60)

#LINE WIDTH SHOULD BE EASILY VIEWABLE AT ALL SIZES - 0.2% OF THE WIDTH SEEMS GOOD
#SEEMS FINE
lineWidth = int(max(imWidth, imHeight) / 500)

lines = []
l = (imWidth / 2 - gapLen - chLen, imHeight /
2, imWidth / 2 - gapLen, imHeight / 2)
lines.append(l)
l = (imWidth / 2 + gapLen, imHeight /
2, imWidth / 2 + gapLen + chLen, imHeight / 2)
lines.append(l)
l = (imWidth / 2, imHeight /
2 - gapLen - chLen, imWidth / 2, imHeight / 2 - gapLen)
lines.append(l)
l = (imWidth / 2, imHeight /
2 + gapLen, imWidth / 2, imHeight / 2 + gapLen + chLen)
lines.append(l)

#GENERATE THE DRAW OBJECT AND DRAW THE CROSSHAIRS
draw = ImageDraw.Draw(im)
draw.line(l, fill="dc322f", width=lineWidth)
for l in lines:
draw.line(l, fill="dc322f", width=lineWidth)

del draw

im.show()


And here is what this code produces:

### Selecting Colours from Images

A nice trick would be able to select the best colour to draw the lines with. So here’s some code that rescales the image to a single pixel, inverts that temporary image and selects out the colour of the single pixel. Hopefully this colour is the average ‘negative’ colour of our original image.

from PIL import ImageChops

#DETERMINE THE BEST COLOR FOR LINES
tmp = im.resize((1, 1), resample=3)
tmp = ImageChops.invert(tmp)
bestColor = tmp.getcolors()[0][1]

#GENERATE THE DRAW OBJECT AND DRAW THE CROSSHAIRS
draw = ImageDraw.Draw(im)
draw.line(l, fill=bestColor, width=lineWidth)
for l in lines:
draw.line(l, fill=bestColor, width=lineWidth)

del draw

im.show()


While we’re at it, let’s look at what the inverted image looks like with im = ImageChops.invert(im):

### Adding a Scale Bar

This is an astronomic image I’m working with (in case you hadn’t guessed), so a knowledge of the scale of the image is very important. I want the scale bar to be about $1/3$ the width of the image.

Here’s the code that worked for me to produce a nice scalebar at the bottom left of the image:

from PIL import Image, ImageDraw, ImageChops, ImageFont

#DRAW A SCALEBAR ON THE IMAGE
physicalWidth = 600  # WIDTH IN ARCSEC
startRatio = 0.3
sbPhysicalWidth = physicalWidth * startRatio
sbPixelWidth = imWidth * startRatio

#IF ON THE DEGREE SCALE
if sbPhysicalWidth > 3600:
divider = 3600
unit = "degree"
#IF ON THE ARCMIN SCALE
elif sbPhysicalWidth > 60:
divider = 60.
unit = "arcmin"
#IF ON THE ARCSEC SCALE
else:
divider = 1.
unit = "arcsec"

#FIND THE WIDTH OF THE BAR TO THE NEAREST WHOLE NUMBER ON GIVEN SCALE
tmpWidth = sbPhysicalWidth / divider
displayPhysicalWidth = int(sbPhysicalWidth / divider)
ratio = displayPhysicalWidth / tmpWidth
sbPhysicalWidth = int(sbPhysicalWidth * ratio)
sbPixelWidth = int(sbPixelWidth * ratio)

#DRAW THE SCALEBAR
l = (imWidth / 20, imHeight - imHeight / 20,
imWidth / 20 + sbPixelWidth, imHeight - imHeight / 20)
draw.line(l, fill=bestColor, width=lineWidth)

text = """%(displayPhysicalWidth)s %(unit)s""" % locals()
fontsize = int(imWidth / 40)
font = ImageFont.truetype("source-sans-pro-regular.ttf", fontsize)
draw.text((imWidth / 20, imHeight - imHeight / 20 - fontsize * 1.3), text, fill=bestColor,
font=font, anchor=None)
del draw
im.show()


I also added an orientation indicator1:

## Adding a Watermark

I want to add a small watermark to the images to show that they came from the PS1 telescope. I have this logo with a transparent background.

For this task we’ll use the Image’s new, blend, paste and alpha_composite methods to achieve our goal.

#ADD THE PS1 LOGO
wmHeight = int(max(imWidth, imHeight) / 20)
imagePath = "ps1.png"
logo = Image.open(imagePath)
(logoWidth, logoHeight) = logo.size
wmWidth = int((wmHeight / float(logoHeight)) * logoWidth)
logo = logo.resize((wmWidth, wmHeight), resample=3)

#CREATE A TRANSPARENT IMAGE THE SIZE OF THE ORIGINAL IMAGE - PASTE THE
#LOGO WHERE REQUIRED
logoPH = Image.new("RGBA", (imWidth, imHeight), color=(0, 0, 0, 0))
logoPH.paste(logo, box=(imHeight / 25, imHeight / 25))

#NOW TONE DOWN THE OPACITY
trans = Image.new("RGBA", (imWidth, imHeight), color=(0, 0, 0, 0))
logo = Image.blend(trans, logoPH, alpha=0.75)

#ADD THE WATERMARK STAMP TO THE ORIGINAL IMAGE
im = Image.alpha_composite(im.convert("RGBA"), logo)


and the result? See for yourselves:

### Adding a Fake Source

Sometimes I want to add in a circle marker to show where a new transient object should appear in the image.

#ADD A TRANSIENT MARKER
draw = ImageDraw.Draw(im)
xy1 = (imWidth / 2 - imWidth / 300, imWidth / 2 - imWidth / 300,
imWidth / 2 + imWidth / 300, imWidth / 2 + imWidth / 300)
xy2 = (imWidth / 2 - imWidth / 170, imWidth / 2 - imWidth / 170,
imWidth / 2 + imWidth / 170, imWidth / 2 + imWidth / 170)
draw.arc(xy2, 0, 360, fill="dc322f")
draw.pieslice(xy1, 0, 361, fill="dc322f")


## Convert to Greyscale

Easy peasy:

im = im.convert("L")


References:

Clark, Alex. 2016. “Python-Pillow-Documentation,” March, 1–144. https://pillow.readthedocs.org/en/3.1.x.

1. note if you are looking up at the night–sky and facing north, east is always on your left  ↩