DEV Community

Cover image for Getting Started with Geocoding Exif Image Metadata in Python3
Jayson DeLancey
Jayson DeLancey

Posted on • Originally published at developer.here.com

Getting Started with Geocoding Exif Image Metadata in Python3

The Exchangeable image file format (Exif) is a standard that’s been around since 1998 to include metadata in image file formats like JPEG, WAV, HEIC, and WEBP. With the proliferation of digital cameras and smart phones with GPS receivers these images often include geolocation coordinates. We’re going to get started with how to read geotagged photographs using Python to make use of the data.

This project will demonstrate how to extract Exif data from a JPEG, how to convert from Degrees Minutes Seconds (DMS) to decimal coordinates, how to group photos by location, and finally how to place a thumbnail image on a map like this.

Finished Map

Let’s get started.

Reading Exif with Python

I’ll be working with Python 3.7 in the examples since it’s almost 2020 and Python 2.7 won't be supported for much longer.

$ python -V
Python 3.7.2
Enter fullscreen mode Exit fullscreen mode

I use virtualenv and virtualenv_wrapper to keep my project dependencies straight and recommend you do as well if you run into any issues installing libraries. There are several options you can use for reading Exif data such as piexif and exifread that you might like to try. As a more general purpose image library, I find Pillow to be helpful and is an update for some of the code samples you may see from Python 2.x examples referencing PIL.

The installation is straightforward with pip install Pillow. With just a few lines of code we can display the Exif data:

#!/usr/bin/env python

from PIL import Image

def get_exif(filename):
    image = Image.open(filename)
    image.verify()
    return image._getexif()

exif = get_exif('image.jpg')
print(exif)
Enter fullscreen mode Exit fullscreen mode

What you get back from this get_exif() function is a dictionary with numeric keys that correspond to various types of data. It isn’t terribly useful unless you know what you are looking for. Fortunately, the library also makes it easy to identify these attributes with human readable labels.

from PIL.ExifTags import TAGS

def get_labeled_exif(exif):
    labeled = {}
    for (key, val) in exif.items():
        labeled[TAGS.get(key)] = val

    return labeled

exif = get_exif('image.jpg')
labeled = get_labeled_exif(exif)
print(labeled)
Enter fullscreen mode Exit fullscreen mode

The label GPSInfo is much more meaningful than 34853 which is the numeric code defined in the standard for identifying the GPS data in Exif. The tags also show a variety of other details like the camera used, image dimensions, settings, etc. beyond just the geotagging results. To get a full list of the tags the Exiv2 Metadata reference table is pretty handy.

GPSInfo = {1: 'N', 2: ((36, 1), (7, 1), (5263, 100)), 3: 'W', 4: ((115, 1), (8, 1), (5789, 100)), 5: b'\x00', 6: (241175, 391), 7: ((19, 1), (8, 1), (40, 1)), 12: 'K', 13: (0, 1), 16: 'T', 17: (1017664, 4813), 23: 'T', 24: (1017664, 4813), 29: '2019:01:11', 31: (65, 1)}
Make = Apple
Model = iPhone 8
Software = 12.1.2
ShutterSpeedValue = (223247, 48685)
DateTimeOriginal = 2019:01:11 11:08:47
FocalLength = (399, 100)
ColorSpace = 65535
ExifImageWidth = 4032
Enter fullscreen mode Exit fullscreen mode

In addition to this subset, there are fields like the MakerNote which can include just about any data hex encoded for various uses and is often where photo software might store IPTC or XP metadata like comments, tags, subjects, titles, etc. that you see in Desktop Applications. I just want the geotagging details for this project which have additional tag constant labels I can reference:

from PIL.ExifTags import GPSTAGS

def get_geotagging(exif):
    if not exif:
        raise ValueError("No EXIF metadata found")

    geotagging = {}
    for (idx, tag) in TAGS.items():
        if tag == 'GPSInfo':
            if idx not in exif:
                raise ValueError("No EXIF geotagging found")

            for (key, val) in GPSTAGS.items():
                if key in exif[idx]:
                    geotagging[val] = exif[idx][key]

    return geotagging

exif = get_exif('image.jpg')
geotags = get_geotagging(exif)
print(geotags)
Enter fullscreen mode Exit fullscreen mode

I now have a dictionary of some key geographic attributes that I can use for this project:

{
    'GPSLatitudeRef': 'N',
    'GPSLatitude': ((36, 1), (7, 1), (5263, 100)),
    'GPSLongitudeRef': 'W',
    'GPSLongitude': ((115, 1), (8, 1), (5789, 100)),
    'GPSTimeStamp': ((19, 1), (8, 1), (40, 1)),
    ...
}
Enter fullscreen mode Exit fullscreen mode

If you are trying to make sense of the GPSLatitude and GPSLongitude values you’ll notice they are stored in degrees, minutes, and seconds format. It’ll be easier to use HERE Services available with your developer account when working in decimal units so we should do that conversion.

EXIF uses rational64u to represent the DMS value which should be straightforward to convert with PIL which has already given us numerator and denominator components in a tuple:

def get_decimal_from_dms(dms, ref):

    degrees = dms[0][0] / dms[0][1]
    minutes = dms[1][0] / dms[1][1] / 60.0
    seconds = dms[2][0] / dms[2][1] / 3600.0

    if ref in ['S', 'W']:
        degrees = -degrees
        minutes = -minutes
        seconds = -seconds

    return round(degrees + minutes + seconds, 5)

def get_coordinates(geotags):
    lat = get_decimal_from_dms(geotags['GPSLatitude'], geotags['GPSLatitudeRef'])

    lon = get_decimal_from_dms(geotags['GPSLongitude'], geotags['GPSLongitudeRef'])

    return (lat,lon)


exif = get_exif('image.jpg')
geotags = get_geotagging(exif)
print(get_coordinates(geotags))
Enter fullscreen mode Exit fullscreen mode

At this point, given an image as the input I’ve produced a latitude and longitude (36.13372, -115.15228) result. To figure out where that is we can use the geocoding service.

Reverse Geocoding

In Turn Text Into HERE Maps with Python NLTK, I demonstrated how to use the Geocoder API to take a city like “Gryfino” and search to identify the latitude and longitude coordinates. Now, I want to do the opposite and reverse geocode the set of coordinates from my image to identify the location.

If you’ve worked with HERE services before you should know all about your API_KEY from the developer projects dashboard. Personally, I like to store these values in a shell script to config my environment as demonstrated in this next snippet.

import os
import requests

def get_location(geotags):
    coords = get_coordinates(geotags)

    uri = 'https://revgeocode.search.hereapi.com/v1/revgeocode'
    headers = {}
    params = {
        'apiKey': os.environ['API_KEY'],
        'at': "%s,%s" % coords,
        'lang': 'en-US',
        'limit': 1,
    }

    response = requests.get(uri, headers=headers, params=params)
    try:
        response.raise_for_status()
        return response.json()

    except requests.exceptions.HTTPError as e:
        print(str(e))
        return {}

exif = get_exif('image.jpg')
geotags = get_geotagging(exif)
location = get_location(geotags)

print(location['items'][0]['address']['label'])
Enter fullscreen mode Exit fullscreen mode

This is pretty much boilerplate use of the Python requests library to work with web services that you can get with a pip install requests. I’m then calling the Reverse Geocoder API endpoint with parameters to retrieve the closest address within 50 meters. This returns a JSON response that I can use to quickly label the photograph with Las Vegas, NV 89109, United States to create an album or whichever level of detail I find useful for an application.

This is a good way for example to process and group your photos by city, state, country or zip code with just a little bit of batch sorting.

Geopy

An alternative approach that can simplify the code a bit is to use the geopy library that you can get with a pip install geopy. It depends on the level of detail and flexibility you want from a full REST request but for simple use cases can greatly reduce the complexity of your code.

from geopy.geocoders import Here

exif = get_exif('image.jpg')
geotags = get_geotagging(exif)
coords = get_coordinates(geotags)
geocoder = Here(apikey=os.environ['API_KEY'])
print(geocoder.reverse("%s,%s" % coords))
Enter fullscreen mode Exit fullscreen mode

The response using geopy in this example is: Location(1457 Silver Mesa Way, Las Vegas, NV 89169, United States, Las Vegas, NV 89169, USA, (36.13146, -115.1328, 0.0)). This can be convenient but customizing your request to the REST endpoint gives you the most flexibility.

Removing Exif from Photos

What if you don’t want geotagging details in photos that you share or put on a public website?

While using the above is handy when working with my own photographs – if trying to sell something on Facebook Marketplace, Craigslist, or eBay I might want to avoid giving away my home address. It’s pretty straightforward to remove the geotagging details by creating a new image and not copying the Exif metadata.

This quick script is a useful metadata removal tool for purposes like that:

import sys
from PIL import Image

for filename in sys.argv[1:]:
    print(filename)

    image = Image.open(filename)
    image_clean = Image.new(image.mode, image.size)
    image_clean.putdata(list(image.getdata()))
    image_clean.save('clean_' + filename)
Enter fullscreen mode Exit fullscreen mode

Placing Markers on a Map for Photos

Switching things up for a moment we’ll turn to web maps. If you haven’t created an interactive map before the Quick Start and other blog posts should help you get started with the Maps API for JavaScript.

I already have the coordinates for my image so all that is left is to generate a small thumbnail that I can use for the marker.

def make_thumbnail(filename):
    img = Image.open(filename)

    (width, height) = img.size
    if width > height:
        ratio = 50.0 / width
    else:
        ratio = 50.0 / height

    img.thumbnail((round(width * ratio), round(height * ratio)), Image.LANCZOS)
    img.save('thumb_' + filename)
Enter fullscreen mode Exit fullscreen mode

This function generates a thumbnail with a max height or width of 50 pixels which is about right for a small icon. Taking the quick start JavaScript I only need to add the next few lines to initialize the icon, place a marker at the coordinates retrieved from Exif with the icon, and then add it for rendering on the map.

thumbnail = new H.map.Icon('./thumb_image.jpg');
marker = new H.map.Marker({lat: 36.13372, lng: -115.15228}, {icon: thumbnail});
map.addObject(marker);
Enter fullscreen mode Exit fullscreen mode

I could even consider dynamically generating this JavaScript from Python if I had many images I'm working with. Firing up a python web server we get our final result.

$ ls
index.html  index.js  thumb_image.jpg
$ python -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
Enter fullscreen mode Exit fullscreen mode

Marker on Map

That photo was of the doorway in between the Las Vegas Convention Center and Westgate, not nearly as exciting as the Valley of Fire State Park pictured initially.

Wrapping Up

Wrapping up, a few final notes that might be helpful. There is Exif support in image formats like WEBP commonly used by Chrome and HEIC (or HEIF) which is the new default on iOS. Some of the supporting Python libraries are still being updated to work with these newer image formats though so require some additional work.

There is also the c++ library exiv2 that is made available in the Python3 package py3exiv2 for reading and writing Exif data but it proved challenging to install with boost-python3 on OSX recently. If you’ve had any luck with these other image formats or libraries, please do share in the comments.

The example project here should demonstrate all the steps you need for an image processing pipeline that can extract geotagged details from photographs and organize by location or place the photo on a map much like you see in apps on a phone.

Top comments (0)