The cover of Joy Division’s 1979 album Unknown Pleasures has entered pop culture and became an iconic image even for people that have never heard the album.
The type of chart is officially called a ridgeline plot but Unknown Pleasures is so influencial that it is also sometimes referred to as a Joyplot.

A few days ago I stumbled upon a post on Reddit where a user created a ridgeline plot/Joyplot of the elevation levels of some US state.
It looked great but they did not provide any code so I decided to implement it myself. I’ll describe how I did it so you can follow along and create your own maps.

## Where to get elevation data?

There are many different data sources for elevation data available for free.
For example the data from NASA’s Shuttle Radar Topography Mission (SRTM) on STS-99 where they outfitted Space Shuttle Endeavour with a radar system to generate the - up till then - best resolution topograhic map of the earth. There is better data available now, but that is not really needed for such a low-res map we’re about to make.

In fact, even the STRM data is much more accurate then we need. It is also split up into one arcsecond tiles so we would have to first stitch them together and extract the digital elevation data which would make the whole project much more difficult.

Luckily there is another source of elevation data everyone is already familiar with. Good old topographic maps.

They are plenty accurate for our needs and easy to find on the internet so I decided to go with that.

Most topographic maps have the same color scheme with blue for water, green for land, yellow for hilly areas and red for mountain ranges.
Conveniently we can (ab)use this (blue to green to red) color arrangement to map the colors in our image to an elevation value via its hue.

HSV cylinder by SharkD CC BY-SA 3.0

Of course this would not precise and the elevation in our plot won’t be to scale with the real world, but our number one priority is just that it has to look cool.

## Validation of our idea

To validate the idea let’s pick some images and plot their hue distribution.

import sys
from PIL import Image
import numpy as np
import matplotlib.pyplot as plt

TARGET_DPI = 72

im = Image.open(sys.argv[1])
# convert to HSV color space
im_hsv = im.convert('HSV')
hsv_arr = np.array(im_hsv)
huemap = hsv_arr[:,:,:1]
huevalues = huemap.flatten()

plt.figure(figsize=(1000/TARGET_DPI, 300/TARGET_DPI), dpi=TARGET_DPI)
plt.subplot(1, 2, 1)
plt.imshow(im)
plt.subplot(1, 2, 2)
plt.hist(huevalues, 50)
plt.savefig("out.png")
plt.show()

This one should work you can clearly see the peak around 250 which corresponds to the blue colors in the sea. We have a lot of greens in the range from 150 to 170. A few yellows and large red portions. White is defined to have a hue of 0° thats why we have a peak at 0 in our histogram.

This is not really a topographic map but the colors are purely based on elevation. While you can’t see it in this histogram the color of the sea should be slightly different from the ones on the coast so it should work but we’ll see.

This one won’t work at all. The big white background area dominates the whole image and per our definition should have a high elevation because it has a low hue value.
But we’re just working with images here, let’s open it in Photoshop, crop it and replace the white background with blue:

Now it looks good again, but you can clearly see the gap between the blues and the greens.
This will result in a big “elevation” difference in our final plot that does not correspond with the real world.
The map of Europe does not have this problem because it is a direct representation of elevation.

## Great, let’s do it!

I’m using Python and Matplotlib to draw the plots. I’ll omit the boring stuff and only show the relevant parts.

### The full code is available on Github.

Required dependencies are Pillow, numpy, matplotlib and scipy:

from PIL import Image
import numpy as np
import matplotlib.pyplot as plt
from scipy.ndimage.filters import gaussian_filter

First let’s load the image and convert it into HSV color space to get the hue, saturation and lightness for each pixel.

# load image
im = Image.open(args.image)
width, height = im.size
# convert to 2D array of hue values
im_hsv = im.convert('HSV')

But since we’re only interested in hue we can discard the rest:

hsv_arr = np.array(im_hsv)
huemap = hsv_arr[:,:,:1]

We now have a 2-dimensional array of hue values representing the input image. Now we apply some corrections to this hue map based on our assumptions on the input image:

# I'm assuming that high elevations are red (~0°),
# low elevations are green (~80°) and water is blue (~150°).
# Red with a blue content could also be at the upper end of the hue circle so i'm just setting it to 1°
huemap[huemap > 300] = 0
# To make the difference between water and land not to high I'm capping the maximum hue
huemap[huemap > 120] = 120

We can now convert this map of hues into a value representing the relative elevation at this point.
Our hues will be somewhere in the range of 0° to 360° so let’s first normalize our data using some very simple math:

$$x’_i = \frac{x_i - min(x)}{max(x) - min(x)}$$

Note: Since our data is now in the range $[0; 1]$ but in our case a higher hue value means a lower elevation value so we have to bring it to $[1; 0]$:

# scale the hue values to be in the range from 0 to 1
scaled = (huemap - np.min(huemap)) / (np.max(huemap) - np.min(huemap))
# invert it because in our case a higher hue value means a lower elevation value
scaled *= -1
# since our data is now in the range [0; -1] lets add 1 to bring it to [1; 0]
scaled += 1
# at last scale it with our custom scaling factor to increase the difference between low and high elevations
scaled *= args.scale

Depending on the input image the resulting elevation values can be very rough so let’s smooth them out a little bit. Because our data is still essentially an image we can just blur it:

# add a blur to smooth the curves
filtered = gaussian_filter(scaled, sigma=args.roughness)

Now we’re ready to plot the data. As already said I’m using Matplotlib:

# plot the curves
t = np.linspace(0, width, filtered.shape[1])
plt.figure(figsize=(width/TARGET_DPI, height/TARGET_DPI), dpi=TARGET_DPI)
# we only want to draw every n-th line where n equals the height of the image divided by the number of lines we want
nth_line = np.floor(height / args.lines)
for y in range(0, height):
if y % nth_line == 0:
yoff = height - y
if args.continuous:
# if continous mode is on we can just plot a single row of out elevation map
plt.plot(t, filtered[y] + yoff, color=args.line_color, linewidth=args.line_width)
else:
# otherwise we have to draw individual segments and hide the ones that are below the threshold
for px1, px2, py1, py2 in zip(t, t[1:], filtered[y], filtered[y][1:]):
line_color = args.background_color
if py1 >= 0.5:
line_color = args.line_color
plt.plot([px1, px2], [py1 + yoff, py2 + yoff], color=line_color, linewidth=args.line_width)

Now we can save out plot and we’re finished.

## Some examples

Varying the number of lines from 50 to 250:

Varying scale from 25 to 100:

Example of different line and background colors:

Not every topographical map you can find works well with this approach.

For best results it should not contain any text or markings and should of course have a nice blue to green to red color scheme. For the map of europe I used this one from the European Environment Agency which worked very well: https://www.eea.europa.eu/data-and-maps/data/digital-elevation-model-of-europe

Felix Stein

1311 Words

2020-04-25 02:00 +0200