Radial Charts API (Part One)

So I have an account over on People Per Hour and came across a fun little project over the weekend. I'm waiting to see whether my proposal will be accepted, so there's a chance this will never be posted (!), but it was an interesting little challenge to solve.

Project Requirements

The requirements were:

  • provide a server-based API (absolutely NOT a front-end) ...
  • ... which generates 'stacked' doughnut charts ...
  • ... with text overlays ...
  • ... and returns an image URL that can then be embedded within a document

An example of the required output image was provided:

Example radial chart

Example image provided


Split It Down

When faced with this kind of project, the key (I believe) is to break it down into component parts. For this project, the key parts are:

  • be able to draw a series of doughnuts into an image
  • be able to add text and icons
  • be able to save the final image
  • have an API that accepts information to generate these images (presumably a POST request with JSON providing the information) and returns a URL
  • have an API that takes an image URL and returns that image

Draw The Doughnuts

So I'll be honest - I started down the route of using MatPlotLib to draw the doughnuts. There were a few reasons for this:

  • The project is about charting, so MatPlotLib felt like the right place to start
  • I've recently learned how to use MatPlotLib and wanted to use that knowledge!
  • The option I had was to use PIL/Pillow, but a quick test of drawing an arc showed that it would be horribly pixelated (PIL/Pillow doesn't anti-alias its curves)

However, there are some downsides of using MatPlotLib. Take for instance the following code:

import matplotlib.pyplot as plt
import matplotlib.patches as patch

figure, axes = plt.subplots(figsize=(10,10))

arc1 = patch.Arc((0.5, 0.5), 0.92, 0.92, theta1=180.0, theta2=90.0, color='red', linewidth=40.0)
axes.add_artist(arc1)

axes.text(0.48, 0.96, "Example 75%", verticalalignment="center", horizontalalignment="right", fontsize=18.0, color='red', fontweight='bold')

plt.show()

Which generates this image:

Matplotlib example

Axes are a a problem

The problems with this are:

  • chart axes are outside the plot area but within the overall image size — whilst we can hide the axes, they’ll still be impacting on image size
  • not obvious, but there are very limited (none?) font options in MatPlotLib
  • we will need to deal with PIL/Pillow regardless, because (AFAIK) there’s no way to add the icons using MatPlotLib

The first problem is the biggie — as soon as we start to use PIL/Pillow on top of MatPlotLib (which means saving the image, whether on disk or in memory, and then reading it into a PIL.Image) we hit problems over exact spacing of the arcs and therefore with lining up text/icons appropriately. This irritated me to the point that I decided to give up on MatPlotLib and instead look to use just PIL/Pillow.


The Problem With Pillow

The reason that I'd begged off using PIL/Pillow to draw the arcs in the first instance was that they weren't being anti-aliased.

What's anti-aliasing? Well, it's complicated, but essentially when you examine a curve or ellipse at the pixel level you see jagged lines where a pixel is either part of the curve or part of the background — anti-aliasing is the process of 'fading' the edges so that when you see the whole image you don't see those jagged edges.

Two circles, one anti-aliased and one not

Example of anti-aliased circle

Turns out that an easy way to deal with this is to draw the arcs to a much larger scale than required, and then downscale — and then the module will anti-alias the downscaled image. I've decided to go over-board and draw my arcs at 4x the required size ...


Drawing Arcs With PIL

First off we need to import PIL/Pillow and get an appropriate-sized image; I want to end up with something that's 1000x1000 after I've downsized it ...

from PIL import Image, ImageDraw
img = Image.new("RGB", (4000,4000), color="white")

Now we want to draw our three arcs; for the sake of ease (and because I want this to work with JSON later on) I'm going to define some data to draw, and define two constants LW (linewidth) and SP (spacing). Lastly we need an ImageDraw object that actually does the drawing ...

data = [
  {'percent': 82.0, 'color': 'purple'},
  {'percent': 75.0, 'color': '#00788a'},
  {'percent': 80.0, 'color': 'gray'}
]
LW = 60
SP = 10
d = ImageDraw.Draw(img, mode="RGBA")

And now to draw the arcs; PIL/Pillow defines it's arcs by the 'bounding box' that they're drawn within, and then the start/end angles of the arc with 0deg pointing 'east' drawing clockwise; since we want an arc that starts at 'north' we have to start from -90deg ...

for idx, item in enumerate(data):
  offset = 4 * ( ( LW + SP ) * idx + SP )
  box = (offset, offset, 4000 - offset, 4000 - offset)
  deg = int(360 * item['percent']/100) - 90
  d.arc(box, -90, deg, fill=item['color'], width=4*LW)

And lastly we need to downsize the image from 4000x4000 to 1000x1000 —the LANCZOS resample parameter is the best one for anti-aliasing — and save it.

img = img.resize((1000,1000), resample=Image.LANCZOS)
img.save('example.png')

And the resulting image ...

THree arcs

Three anti-aliased arcs!

Next time we'll look at how we can add text and icons ...