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.
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)