Hire Me! I'm currently looking for my next role in developer relations and advocacy. If you've got an open role and think I'd be a fit, please reach out. You can also find me on LinkedIn.

I'm not sure how useful this will be, but as I recently built it in another language (I plan on blogging that soon as well), I thought I'd take a stab at building it in Python. Given a folder of images, can I use Python to grab the Exif information and then using that, figure out where the photos were taken using a reverse geocoding service? Here's what I built.

First - Get the Images

Ok, the first step is simple, just get a list of images from a directory:

INPUT = "./sources"

files = os.listdir(INPUT)

for file in files:
	print(file)

Woot! I'm a Python Master!

Get the Exif info

For the next step, I knew I needed to get the Exif info. For that I used the Pillow library, which has a handy getexif() method:

img = PIL.Image.open(os.path.join(INPUT, file))

img_exif = img.getexif()

However, this doesn't return what I expected:

{296: 2, 282: 72.0, 256: 4000, 257: 3000, 34853: 754, 34665: 248, 271: 'samsung', 272: 'Galaxy S24 Ultra', 305: 'S928U1UES4AXKF', 274: 1, 306: '2025:01:06 07:35:55', 531: 1, 283: 72.0}

From what I can tell, the getexif method returns only a subset of exif information, not all the possible tags that may exist. That makes some sense as different hardware/services may write ad hoc exif data.

I did some Googling and this StackOverflow answer provided an interesting hack. This code will search for the GPSInfo tag and return the numeric value:

GPSINFO_TAG = next(
	tag for tag, name in PIL.ExifTags.TAGS.items() if name == "GPSInfo"
)  # should be 34853

You can then combine this to get the right value:

gpsinfo = img_exif.get_ifd(GPSINFO_TAG)

Whew. This returns a dictionary that looks like so:

{1: 'N', 2: (47.0, 30.0, 2.952359), 3: 'E', 4: (19.0, 2.0, 42.89964), 5: 0, 6: 143.0}

This is the direction and GPS info in degrees and minutes returned in a Python dictionary. To convert the values, I did this:

if len(gpsinfo):
	latitude = f"{gpsinfo[2][0]}°{gpsinfo[2][1]}'{gpsinfo[2][2]}\" {gpsinfo[1]}"        
	longitude = f"{gpsinfo[4][0]}°{gpsinfo[4][1]}'{gpsinfo[4][2]}\" {gpsinfo[3]}"

I then needed to convert the longitude and latitude to decimal. For that... I turned to AI. Yep, I cheated. But guess what, it worked? Gemini spit out this function:

def dms_to_dd(dms_str):
	"""Converts a DMS string to decimal degrees.

	Args:
		dms_str: A string representing the angle in DMS format 
				 (e.g., "36°57'9" N", "110°4'21" W").

	Returns:
		The angle in decimal degrees.
	"""

	parts = re.split(r'[^\d\w\.]+', dms_str.strip())
	degrees = float(parts[0])
	minutes = float(parts[1]) if len(parts) > 1 else 0
	seconds = float(parts[2]) if len(parts) > 2 else 0
	direction = parts[3].upper() if len(parts) > 3 else 'E'  # Default to East

	dd = degrees + minutes / 60 + seconds / 3600
	if direction in ('S', 'W'):
		dd *= -1
	return dd

As I had recently did something just like this, I was able to eyeball it and confirm it made sense. I will say the comment is slightly off as the input isn't the full address, but only one portion.

Reverse Geocoding

Now that I had a location, I needed to reverse geocode, which is basically, "Given a location, what the heck is there?" Mapbox has an excellent free API for this, so I built a quick wrapper:

def reverseGeoCode(lon, lat):
	res = requests.get(f"https://api.mapbox.com/search/geocode/v6/reverse?longitude={lon}&latitude={lat}&access_token={MAPBOX_KEY}")
	return res.json()

And called it like so:

loc = reverseGeoCode(dms_to_dd(longitude), dms_to_dd(latitude))

This returns a great deal of information in GeoJSON format which I'm familiar with because of my time at HERE. In my mind, I was imagining printing results for public consumption, so while a heck of a lot of data was returned, I simply wanted a formatted address:

print(loc["features"][1]["properties"]["place_formatted"])

You can check the Mapbox docs if you want to see more of the results, but that worked just fine for me.

The Results

When I ran it, I got exactly what I expected:

20250108_101553.jpg
Bratislava, Bratislava, Slovakia
--------------------------------------------------------------------------------
laf.jpg
Lafayette, Louisiana, United States
--------------------------------------------------------------------------------
20250107_160104.jpg
Győr, Győr-Moson-Sopron, Hungary
--------------------------------------------------------------------------------
20250111_170109.jpg
Grein, Upper Austria, Austria
--------------------------------------------------------------------------------
20250112_154722.jpg
Salzburg, Salzburg, Austria
--------------------------------------------------------------------------------
20250106_073555.jpg
Budapest, Hungary
--------------------------------------------------------------------------------

And if you're curious, here is each of those images, in the same order as above. (I edited the laf.jpg one to not have my precise address. Sorry. ;) The complete source code for this script will be at the end.

Bratislava, Bratislava, Slovakia

Lafayette, Louisiana, United States

Győr, Győr-Moson-Sopron, Hungary

Grein, Upper Austria, Austria

Salzburg, Salzburg, Austria

Budapest, Hungary

The Script

import os 
import PIL.Image 
import PIL.ExifTags
import re 
import requests

MAPBOX_KEY = os.environ.get('MAPBOX_KEY')

def reverseGeoCode(lon, lat):
	res = requests.get(f"https://api.mapbox.com/search/geocode/v6/reverse?longitude={lon}&latitude={lat}&access_token={MAPBOX_KEY}")
	return res.json()

def dms_to_dd(dms_str):
	"""Converts a DMS string to decimal degrees.

	Args:
		dms_str: A string representing the angle in DMS format 
				 (e.g., "36°57'9" N", "110°4'21" W").

	Returns:
		The angle in decimal degrees.
	"""

	parts = re.split(r'[^\d\w\.]+', dms_str.strip())
	degrees = float(parts[0])
	minutes = float(parts[1]) if len(parts) > 1 else 0
	seconds = float(parts[2]) if len(parts) > 2 else 0
	direction = parts[3].upper() if len(parts) > 3 else 'E'  # Default to East

	dd = degrees + minutes / 60 + seconds / 3600
	if direction in ('S', 'W'):
		dd *= -1
	return dd

# Credit: https://stackoverflow.com/a/73745613/52160
GPSINFO_TAG = next(
	tag for tag, name in PIL.ExifTags.TAGS.items() if name == "GPSInfo"
)  # should be 34853

INPUT = "./sources"

files = os.listdir(INPUT)

for file in files:
	print(file)
	img = PIL.Image.open(os.path.join(INPUT, file))

	img_exif = img.getexif()
	
	gpsinfo = img_exif.get_ifd(GPSINFO_TAG)

	if len(gpsinfo):
		latitude = f"{gpsinfo[2][0]}°{gpsinfo[2][1]}'{gpsinfo[2][2]}\" {gpsinfo[1]}"        
		longitude = f"{gpsinfo[4][0]}°{gpsinfo[4][1]}'{gpsinfo[4][2]}\" {gpsinfo[3]}"

		loc = reverseGeoCode(dms_to_dd(longitude), dms_to_dd(latitude))
		print(loc["features"][1]["properties"]["place_formatted"])

	print('-' * 80)