Skip to content

Instantly share code, notes, and snippets.

@brandon-rhodes
Last active April 26, 2024 13:44
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save brandon-rhodes/6455705 to your computer and use it in GitHub Desktop.
Save brandon-rhodes/6455705 to your computer and use it in GitHub Desktop.
Script that builds a Google Earth overlay that projects the map of Tolkien’s Middle-earth atop modern Europe at the correct position and scale. Once the .kmz overlay file has been generated, simple open it using the File menu in Google Earth.
"""Project a map of Middle-earth on modern Europe.
Builds an `overlay.kmz` file in the current directory which should be
opened with Google Earth.
"""
import os
import urllib2
import zipfile
from math import cos, radians
from PIL import Image, ImageDraw, ImageFont
# An image and where it comes from.
filename = 'gmiddleearth2.jpg'
url = 'http://www.anarda.net/tolkien/dibujos/mapas/' + filename
# Values taken from the map.
x_rivendell = 696 # x of Rivendell
x_hobbiton = 360 # x of Hobbiton
y_hobbiton = 437 # y of Hobbiton
x_scale0 = 128 # x of left edge of "Miles" scale
x_scale300 = 363 # x of right edge of "Miles" scale
# Google search for "oxford england lat lon".
lat_hobbiton = 51.7519 # Oxford latitude
lon_hobbiton = -1.2578 # Oxford longitude
# Since we approximate the Earth as a sphere, instead of a full WGS84
# ellipsoid, this value involves some fudging: it has been adjusted
# until it makes the Middle-earth map scale, when project on the Google
# Earth globe, exactly 300 miles in length.
miles_per_degree = 67.7 # per degree of latitude
# Per the hypotheses of Lalaith, the map's scale only applies to the
# central meridian that runs vertically through Rivendell, and along
# lines of latitude. See: http://lalaith.vpsurf.de/Tolkien/Grid.html
miles_per_pixel = 300.0 / (x_scale300 - x_scale0)
latitude_per_pixel = miles_per_pixel / miles_per_degree
# And, finally, the code.
def download_and_open_image():
"""Returns an open Image containing the Middle-earth map."""
if not os.path.exists(filename):
data = urllib2.urlopen(url).read()
with open(filename, 'wb') as f:
f.write(data)
return Image.open(filename)
def latitude(y):
"""Return the latitude, in radians, of a given `y` map image coordinate."""
return lat_hobbiton + (y_hobbiton - y) * latitude_per_pixel
def mag(y):
"""How much do we have to expand each row of the image horizontally?"""
return 1. / cos(radians(latitude(y)))
def scale_map():
"""Return the Middle-earth map, scaled to project on a Google Earth."""
im = download_and_open_image()
xsize, ysize = im.size
# Find the location of the FreeSans font by asking the "locate" command,
# rather than hard-coding its location in a string constant.
font_path = os.popen('locate -n1 /FreeSans.ttf').read().strip()
# Remove the right margin to place Rivendell exactly in the center,
# since Lalaith believes that to be the map's "central meridian".
xsize = 2 * x_rivendell
im = im.crop((0, 0, xsize, ysize))
# Draw red lines of latitude at five-degree increments, labeled in
# blue in a simple sans-serif font, to make it easy to verify that
# the image is placed correctly on the Google Earth globe.
font = ImageFont.truetype(font_path, 24)
draw = ImageDraw.Draw(im)
for lat in range(30, 61, 5):
y = y_hobbiton + (lat_hobbiton - lat) / latitude_per_pixel
draw.line((0, y, xsize, y), fill=(255,0,0))
draw.text((xsize - 50, y), u'%sN' % lat, font=font, fill=(0,0,255))
del draw
# How big will the whole image be? The top row, at y=0, will be widest.
xfinal = int(xsize * mag(0))
# Build the mesh that stretches each line of pixels in the original
# image into a wider line of pixels in the resulting image.
middle = x_rivendell * mag(0) # x-coordinate on which to center
data = []
for y in range(0, ysize):
m = mag(y)
source_quad = (0, y-0.5, 0, y+0.5,
xsize, y+0.5, xsize, y-0.5) # the whole row of pixels
dest_bbox = (int(middle - m * x_rivendell), y,
int(middle + m * x_rivendell), y + 1) # a wider row
data.append((dest_bbox, source_quad))
# Do the transform and save the result.
im = im.transform((xfinal, ysize), Image.MESH, data, Image.BICUBIC)
return im, xfinal, xsize, ysize
def build_overlay():
"""Build the whole overlay, with the KML and image inside of it."""
im, xfinal, xsize, ysize = scale_map()
im.save('middle-earth-scaled.jpg')
rivendell_hobbiton_miles = (x_rivendell - x_hobbiton) * miles_per_pixel
lon_rivendell = lon_hobbiton + (
rivendell_hobbiton_miles / miles_per_degree * mag(y_hobbiton))
with zipfile.ZipFile('middle-earth-overlay.kmz', 'w') as z:
z.writestr('doc.kml', kml.format(
path='files/middle-earth-scaled.jpg',
lat_north=latitude(0),
lat_south=latitude(ysize),
lon_east=lon_rivendell + xsize / 2.0 * latitude_per_pixel * mag(0),
lon_west=lon_rivendell - xsize / 2.0 * latitude_per_pixel * mag(0),
))
z.write('middle-earth-scaled.jpg', 'files/middle-earth-scaled.jpg')
# KML Mad Libs.
kml = """\
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2" xmlns:kml="http://www.opengis.net/kml/2.2" xmlns:atom="http://www.w3.org/2005/Atom">
<GroundOverlay>
<name>Map of Middle-earth</name>
<color>aaffffff</color>
<Icon>
<href>{path}</href>
<viewBoundScale>0.75</viewBoundScale>
</Icon>
<LatLonBox>
<north>{lat_north}</north>
<south>{lat_south}</south>
<east>{lon_east}</east>
<west>{lon_west}</west>
</LatLonBox>
</GroundOverlay>
</kml>
"""
if __name__ == '__main__':
build_overlay()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment