Using Python To Make A Map With Accurate Longitude/Latitude Axes From The Database At OpenStreetMap.Org

Introduction

In order to work with the map database at openstreetmap.org the website has extensive information and one can find very many software packages available for developers of different kinds of uses for maps. This author had a particular application for maps where the longitude and latitude grid is to be registered accurately with the map image. Furthermore, in developing the application it was desired to use a general purpose programming tool to accomplish this and to avoid downloading and learning to use any of the software packages listed at openstreetmap.org. In this case that programming tool was python3 and its various powerful libraries.

In order to obtain and work with the database one needs to understand Slippy maps. This web page describes everything to know about Slippy map tiles and how to get them.

Slippy Maps

A brief tutorial is needed but without the detail found in the referenced web page.

The openstreetmap.org database is constructed with tiles, each tile being a 256 pixel x 256 pixel .png image file. There are 20 directories where each directory contains all of the tiles that cover the world, where the world is a Mercator projection that one commonly sees when looking at any map.

The directories are called zoom levels, and are enumerated 0 thru 19. Directory zoom level 0 contains a single tile. Zoom level 1 contains 4 tiles, creating a 2 x 2 grid that covers the world. Greater resolution is found as the zoom level increases. Zoom level 12 consists of a 4096 x 4096 grid of tiles covering the world. The following image taken from the referenced web page depicts the grid for zoom level 2.

Zoom level 2

Tiles are enumerated left to right (x) and top to bottom (y). Note this is done in the image. The zoom level and x, y enumeration allows one to specify each particular png file.

Web clients can download tiles from the openstreetmap.org web servers by sending http requests. The format is: https://tile.openstreetmap.org/zoom/x/y.png

There are three subdomain servers from which one can also download tiles. In the url “tile” is replaced with “a.tile” or “b.tile” or “c.tile”. At this point it is important to note that openstreetmap.org has a policy to limit overloading its servers which occurs by specifying high zoom levels together with building a large area map. A map is constructed by assembling the tiles in a grid and clearly the zoom level defines the resolution of the map. However, the user should trade off resolution and make do with fewer tiles to avoid needing to download hundreds of tiles.

Lastly, it is most important to know how to translate a longitude/latitude coordinate into the particular tile designation in which that coordinate is contained, that is, to find zoom/x/y.png. For python code this calculation is as follows:

import math
def deg2num(lat_deg, lon_deg, zoom):
  lat_rad = math.radians(lat_deg)
  n = 2.0 ** zoom
  xtile = int((lon_deg + 180.0) / 360.0 * n)
  ytile = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
  return (xtile, ytile)

Now to emphasize the point that is the reason for this post. All one knows after downloading a tile is that the longitude/latitude point specified in the above function lies somewhere in the obtained tile, but not where. Therefore, one can build a map with a grid of connected downloaded tiles around some point on earth, but to calibrate that map more work will be needed.

Building A Map With Python

A very good piece of python code derived from “BerndGit” at stackoverflow.com is used to show how to build a map in python.


#import matplotlib.pyplot as plt
import math
import requests
from io import BytesIO
from PIL import Image
import matplotlib
import sys
    

def deg2num(lat_deg, lon_deg, zoom):
  lat_rad = math.radians(lat_deg)
  n = 2.0 ** zoom
  xtile = int((lon_deg + 180.0) / 360.0 * n)
  ytile = int((1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi) / 2.0 * n)
  return (xtile, ytile)
    
def num2deg(xtile, ytile, zoom):
  n = 2.0 ** zoom
  lon_deg = xtile / n * 360.0 - 180.0
  lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n)))
  lat_deg = math.degrees(lat_rad)
  return (lat_deg, lon_deg)
   
def getImageCluster(lat_deg, lon_deg, delta_lat,  delta_long, zoom):
    headers = {"User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36"}
    smurl = r"http://a.tile.openstreetmap.org/{0}/{1}/{2}.png"
    xmin, ymax =deg2num(lat_deg, lon_deg, zoom)
    xmax, ymin =deg2num(lat_deg + delta_lat, lon_deg + delta_long, zoom)
   
    Cluster = Image.new('RGB',((xmax-xmin+1)*256-1,(ymax-ymin+1)*256-1) ) 
    for xtile in range(xmin, xmax+1):
        for ytile in range(ymin,  ymax+1):
            try:
                imgurl = smurl.format(zoom, xtile, ytile)
                print("Opening: " + imgurl)
                imgstr = requests.get(imgurl, headers=headers)
                tile = Image.open(BytesIO(imgstr.content))
                Cluster.paste(tile, box = ((xtile-xmin)*256 ,  (ytile-ymin)*255))
            except: 
                print("Couldn't download image")
                tile = None
    Cluster.save("my_image2.png")  
    return Cluster
        a = getImageCluster(lat_deg, lon_deg, delta_lat,  delta_lon, zoom)
    
    
if __name__ == '__main__':

    top, bot = 37.843634, 37.620166 #finished image top latitude and bottom latitude      
    lat_deg=bot   
    delta_lat=top-bot
    lef, rgt = -121.963417, -121.553999 #finished image left longitude and right longitude  
    lon_deg=lef  
    delta_lon=rgt-lef    
    zoom = 12
    a = getImageCluster(lat_deg, lon_deg, delta_lat,  delta_lon, zoom)
   

A few items need to be explained. The function “num2deg” is not used here but will be shown what it does later. The library “matplotlib” is not used here but will be later. The inputs to this function are the latitudes and longitudes that define the boundaries of the desired map, that is, “top, bot” and “lef, rgt”, and also the desired zoom level. Imports from python libraries “io” and “PIL” do the heavy lifting. After the downloaded tiles are pasted together the final map is saved to a png file.

The last item needing explanation is the json “headers” key that is passed to the “requests.get” function. The need for this key is found at this web site. The following image shows the map image built with this code.

Map built with above code

Now given how this works and explained above the bounding box, “top, bot” and “lef, rgt”, lies within this map image with its four corners near the respective four corners of the map image within 255 pixels of the x and y directions. Stated another way, this map image of pasted-together tiles is somewhat larger than the requested map, which is contained within.

So, how does one now apply a set of longitude and latitude axes to this created map? There are two ways and the following starts with the first way.

Finding the Longitude/Latitude Corners

At the same web post at stackoverflow.com another user “etna” builds upon the above code and uses the function “num2deg” to find the longitude and latitude coordinates of the four corners of map image that was created. That is, the map image that is somewhat larger than the image bounded by “top, bot” and “lef, rgt”. The following code shows how this is done.

import matplotlib.pyplot as plt
import math
import requests
from io import BytesIO
from PIL import Image
import matplotlib
import sys

def deg2num(lat_deg, lon_deg, zoom):
  lat_rad = math.radians(lat_deg)
  n = 2.0 ** zoom
  xtile = int((lon_deg + 180.0) / 360.0 * n)
  ytile = int((1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi) / 2.0 * n)
  return (xtile, ytile)
    
def num2deg(xtile, ytile, zoom):
  """
  http://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
  This returns the NW-corner of the square. 
  Use the function with xtile+1 and/or ytile+1 to get the other corners. 
  With x
"""
  n = 2.0 ** zoom
  lon_deg = xtile / n * 360.0 - 180.0
  lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n)))
  lat_deg = math.degrees(lat_rad)
  return (lat_deg, lon_deg)
   
def getImageCluster(lat_deg, lon_deg, delta_lat,  delta_long, zoom):
    headers = {"User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36"}
    smurl = r"http://a.tile.openstreetmap.org/{0}/{1}/{2}.png"
    xmin, ymax =deg2num(lat_deg, lon_deg, zoom)
    xmax, ymin =deg2num(lat_deg + delta_lat, lon_deg + delta_long, zoom)

    #the four corners of the map are calculated
    bbox_ul = num2deg(xmin, ymin, zoom) #bbox_ul[0]=top, bbox[1]=lef
    bbox_ll = num2deg(xmin, ymax + 1, zoom) #bbox_ll[0]=bot, bbox_ll[1]=lef
    print (bbox_ul, bbox_ll) #the left corners

    bbox_ur = num2deg(xmax + 1, ymin, zoom) #bbox_ur[0]=top, bbox_ur[1]=rgt
    bbox_lr = num2deg(xmax + 1, ymax +1, zoom) #bbox_ll[0]=bot, bbox_lr[1]=rgt
    print (bbox_ur, bbox_lr) #the right corners

   
    Cluster = Image.new('RGB',((xmax-xmin+1)*256-1,(ymax-ymin+1)*256-1) ) 
    for xtile in range(xmin, xmax+1):
        for ytile in range(ymin,  ymax+1):
            try:
                imgurl = smurl.format(zoom, xtile, ytile)
                print("Opening: " + imgurl)
                imgstr = requests.get(imgurl, headers=headers)
                tile = Image.open(BytesIO(imgstr.content))
                Cluster.paste(tile, box = ((xtile-xmin)*256 ,  (ytile-ymin)*255))
            except: 
                print("Couldn't download image")
                tile = None
    #Cluster.save("my_image2.png")  
    return Cluster, [bbox_ll[1], bbox_ll[0], bbox_ur[1], bbox_ur[0]] #the new lef, bot, rgt, top

if __name__ == '__main__':
    
    top, bot = 37.843634, 37.620166 #desired image top latitude and bottom latitude      
    lat_deg=bot   
    delta_lat=top-bot
    lef, rgt = -121.963417, -121.553999 #desired image left longitude and right longitude  
    lon_deg=lef  
    delta_lon=rgt-lef    
    zoom = 12
    a, bbox = getImageCluster(lat_deg, lon_deg, delta_lat,  delta_lon, zoom)     
    print ("input coordinates: " + str(top) + ", " + str(bot) + ", " + str(lef) + ", " + str(rgt))
    #save the original requested map
    lat = [ top, bot, top, bot ]
    lon = [ lef, lef, rgt, rgt ]

    #new bounding coordinates of downloaded, created map
    top, bot = bbox[3], bbox[1]  #latitudes
    lef, rgt = bbox[0], bbox[2]  #longitudes
    print ("resulting output coordinates: " + str(top) + ", " + str(bot) + ", " + str(lef) + ", " + str(rgt))

    img = a #this is now the base image to plot
    fig, ax = plt.subplots(figsize=(12,12))
    ax.scatter(lon, lat, alpha=0.5, c='b', s=10)
    ax.set_ylim(bot, top)
    ax.set_xlim(lef, rgt)
    #draw bounding lines showing the original map request
    plt.axvline(x = lon[0], color = 'r',  lw=0.9, ls='--' )
    plt.axvline(x = lon[2], color = 'r',  lw=0.9, ls='--' )
    plt.axhline(y = lat[0], color = 'r',  lw=0.9, ls='--' )
    plt.axhline(y = lat[1], color = 'r',  lw=0.9, ls='--' )
    ax.imshow(img, extent=(lef, rgt, bot, top), aspect="equal")
    filesave = "visual.png"
    plt.savefig(filesave, dpi=300, bbox_inches='tight', pad_inches=0)


In the function “num2deg” a comment has been added to describe what this function returns. In the function “getImageCluster” the calculation for the longitude/latitude coordinates for the four corners of the map is done. This produces new values for “top, bot” and “lef, rgt” which are then returned to the calling program along with the image “a”.

The code then proceeds to use matplotlib to develop a .png image of the map with accurate longitude/latitude axes. Additionally, the code shows the bounding box of the requested input values. The resulting image clearly shows that the requested box lies within the map created by pasting together the downloaded tiles even though the right edge is very near the map’s’s right edge in this particular case of values chosen for “top, bot” and “lef, rgt”.

Map with longitude/latitude axes and showing the bounding box of original request

Cropping the Map to Produce the Requested Map

There may be reason for the user to require a map precisely as requested by “top, bot” and “lef, rgt”, the longitude and latitude values. The way to do this is to crop the map image that results from the above code.

In the above code the python PIL library was used to create the image of the map “Cluster”. One now wants to crop this image using “Cluster.crop(….)”. To do this one needs to convert longitude values and latitude values to number of pixels since the crop function requires the pixel coordinates of the top left corner of the target image and its bottom right corner referenced to top left corner of “Cluster” as an origin.

To begin one calculates the pixel coordinates of the top left corner of “Cluster”. This corner is the top left tile that was downloaded, and in the above code its tile number coordinates are “xmin” and “ymin”.

#xmin and ymin calculated here in getImageCluster(...) 

xmin, ymax =deg2num(lat_deg, lon_deg, zoom)
xmax, ymin =deg2num(lat_deg + delta_lat, lon_deg + delta_long, zoom)

#in the example xmin=660 and ymin=1582
#now x,y can be calculated and these pixel coordinates become the origin for cropping

x, y = xmin * TILE_SIZE, ymin * TILE_SIZE #num pixels to left edge and num pixels to top edge

Now as stated earlier the “crop” function needs the pixel coordinates of the top left corner and the bottom right corner of the desired image. The longitude/latitude coordinates of these two points are the input request, “top, lef” and “bot, rgt”, respectively. To find the pixel coordinates a function similar to “deg2num” is needed and one can call it “deg2pix”.

#note that this function is almost identical to deg2num

TILE_SIZE = 256
def deg2pix(lat_deg, lon_deg, zoom):
  r = math.pow(2, zoom) * TILE_SIZE #total number of pixels at this zoom level
  lat_rad = math.radians(lat_deg)
  xt = int((lon_deg + 180.0) / 360.0 * r)
  y = int((1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi) / 2.0 * r)
  return (x, y) #number of pixels to the point lon_deg and lat_deg, respectively

Now one can add to the function “getImageCluster(…)” and obtain the cropped image.

#x0, y0 corresponds to the top, lef point
#x1, y1 corresponds to the bot, rgt point

x0, y0 = deg2pix(lat_deg + delta_lat, lon_deg   , zoom)
x1, y1 = deg2pix( lat_deg, lon_deg + delta_long  , zoom)

Cluster = Cluster.crop((
        x0 - x,  # left
        y0 - y,  # top
        x1 - x,  # right
        y1 - y)) # bottom

In the numerical example this cropping operation is depicting in the next image.

Cropping of the map

Final Code-Cropped Image

Puttting the above together into final code and showing the resulting map:


import matplotlib.pyplot as plt
import numpy as np
import math
import requests
from io import BytesIO
from PIL import Image
import matplotlib
import sys

TILE_SIZE = 256
def deg2pix(lat_deg, lon_deg, zoom):
  r = math.pow(2, zoom) * TILE_SIZE
  lat_rad = math.radians(lat_deg)
  x = int((lon_deg + 180.0) / 360.0 * r)
  y = int((1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi) / 2.0 * r)
  return (x, y)


def deg2num(lat_deg, lon_deg, zoom):
  lat_rad = math.radians(lat_deg)
  n = 2.0 ** zoom
  xtile = int((lon_deg + 180.0) / 360.0 * n)
  ytile = int((1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi) / 2.0 * n)
  return (xtile, ytile)
    
def num2deg(xtile, ytile, zoom):
  n = 2.0 ** zoom
  lon_deg = xtile / n * 360.0 - 180.0
  lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n)))
  lat_deg = math.degrees(lat_rad)
  return (lat_deg, lon_deg)
   
def getImageCluster(lat_deg, lon_deg, delta_lat,  delta_long, zoom):
    headers = {"User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36"}
    smurl = r"http://a.tile.openstreetmap.org/{0}/{1}/{2}.png"
    xmin, ymax =deg2num(lat_deg, lon_deg, zoom) #tile coordinates of bottom left tile
    xmax, ymin =deg2num(lat_deg + delta_lat, lon_deg + delta_long, zoom)  #tile coordinates of top right tile
    #these are tile coordinates, not number of pixels
#    print ("xmin, ymax: " + str(xmin) + "," + str(ymax) + " tile coordinates of bottom left tile")
#    print ("xmax, ymin: " + str(xmax) + ","  + str(ymin) + " tile coordinates of top right tile")

    Cluster = Image.new('RGB',((xmax-xmin+1)*256-1,(ymax-ymin+1)*256-1) ) #the arguments are number of pixels 
    for xtile in range(xmin, xmax+1):
        for ytile in range(ymin,  ymax+1):
            try:
                imgurl = smurl.format(zoom, xtile, ytile)
                print("Opening: " + imgurl)
                imgstr = requests.get(imgurl, headers=headers)
                tile = Image.open(BytesIO(imgstr.content))
                Cluster.paste(tile, box = ((xtile-xmin)*256 ,  (ytile-ymin)*255))
            except: 
                print("Couldn't download image")
                tile = None

    x, y = xmin * TILE_SIZE, ymin * TILE_SIZE #num pixels to left edge and num pixels to top edge
    #cannot obtain x0, y0  and x1, y1 without defining deg2pix function whereas the deg2num function
    #has TILESIZE multiplied times the lat_rad  
    x0, y0 = deg2pix(lat_deg + delta_lat, lon_deg   , zoom)
    x1, y1 = deg2pix( lat_deg, lon_deg + delta_long  , zoom)
#    print ("x0, y0: " + str(x0) + "," +str(y0) + " number of pixels x and y to the top left corner of finished image") 
#    print ("x1, y1: " + str(x1) + "," +str(y1) + " number of pixels x and y to the bottom right corner of finished image") 
#    print ("x, y: " + str(x) + "," + str(y) + " actual x, y pixels of the left edge and top edge of the top left tile") 

    Cluster = Cluster.crop((
        x0 - x,  # left
        y0 - y,  # top
        x1 - x,  # right
        y1 - y)) # bottom
    return Cluster
    
    
    
if __name__ == '__main__':
    top, bot = 37.843634, 37.620166 #finished image top latitude and bottom latitude      
    lat_deg=bot   
    delta_lat=top-bot
    lef, rgt = -121.963417, -121.553999 #finished image left longitude and right longitude  
    lon_deg=lef  
    delta_lon=rgt-lef    
    zoom = 12
    a = getImageCluster(lat_deg, lon_deg, delta_lat,  delta_lon, zoom)
    img = a #this is now the base image to plot
    fig, ax = plt.subplots(figsize=(12,12))
    ax.set_ylim(bot, top)
    ax.set_xlim(lef, rgt)
    ax.imshow(img, extent=(lef, rgt, bot, top), aspect="equal")
    filesave = "visual_cropped.png"
    plt.savefig(filesave, dpi=300, bbox_inches='tight', pad_inches=0)
Map image with axes exactly as requested

You may also like...

1 Response

  1. Alex says:

    Thank you for the detailed explanation and the code! This is really helpful to understand how one can use OSM map (tiles).

Leave a Reply

Your email address will not be published. Required fields are marked *