Pillow + piexif EXIF GPS tags: working versions (tested 2026-10-15)
Read and write decimal GPS coordinates as EXIF rationals on JPEGs with piexif, then cross-check the same tags through Pillow's Exif IFD.
passed6 passed · 0 failed2026-10-15 16:03 UTC- Status
- passed
- Last passed
- 2026-10-15 16:03 UTC
- Tests
- 6 / 6
- Language
- Python
- Runtime
- Python 3.13.16
pinned versions3
pillow@12.3.0piexif@1.1.3pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
test environment
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38764699834
The code
Exactly the files that passed. Your agent can pull them with get_recipe("pillow-piexif-gps") (install), or see them on GitHub.
requirements.txt
pillow==12.3.0 piexif==1.1.3 pytest==9.1.1
gps.py
import piexif
from PIL import Image
def new_exif():
return {"0th": {}, "Exif": {}, "GPS": {}, "Interop": {}, "1st": {}, "thumbnail": None}
def gps_ifd(latitude, longitude):
return {
piexif.GPSIFD.GPSVersionID: (2, 3, 0, 0),
piexif.GPSIFD.GPSLatitudeRef: b"N" if latitude >= 0 else b"S",
piexif.GPSIFD.GPSLatitude: _dms(latitude),
piexif.GPSIFD.GPSLongitudeRef: b"E" if longitude >= 0 else b"W",
piexif.GPSIFD.GPSLongitude: _dms(longitude),
}
def _dms(value):
value = abs(value)
degrees = int(value)
minutes = int((value - degrees) * 60)
seconds = (value - degrees - minutes / 60) * 3600
return ((degrees, 1), (minutes, 1), (round(seconds * 10000), 10000))
def _degrees(rationals):
(d, dn), (m, mn), (s, sn) = rationals
return d / dn + m / mn / 60 + s / sn / 3600
def set_gps(path, latitude, longitude):
exif = piexif.load(str(path))
exif["GPS"] = gps_ifd(latitude, longitude)
piexif.insert(piexif.dump(exif), str(path))
def read_gps(path):
gps = piexif.load(str(path))["GPS"]
if not gps:
return None
latitude = _degrees(gps[piexif.GPSIFD.GPSLatitude])
longitude = _degrees(gps[piexif.GPSIFD.GPSLongitude])
if gps[piexif.GPSIFD.GPSLatitudeRef] in (b"S", "S"):
latitude = -latitude
if gps[piexif.GPSIFD.GPSLongitudeRef] in (b"W", "W"):
longitude = -longitude
return latitude, longitude
tests/test_gps.py
import struct
import piexif
import pytest
from PIL import ExifTags, Image
import gps
def make_jpeg(path):
Image.new("RGB", (8, 8), "white").save(path, "jpeg")
return path
def test_roundtrip_negative_coordinates(tmp_path):
path = make_jpeg(tmp_path / "a.jpg")
gps.set_gps(path, -33.8688, 151.2093)
assert gps.read_gps(path) == pytest.approx((-33.8688, 151.2093), abs=1e-3)
def test_float_dms_seconds_are_rejected():
exif = gps.new_exif()
exif["GPS"] = {piexif.GPSIFD.GPSLatitude: ((51, 1), (30, 1), (12.5, 1))}
with pytest.raises(struct.error):
piexif.dump(exif)
def test_piexif_and_pillow_disagree_on_types(tmp_path):
path = make_jpeg(tmp_path / "a.jpg")
gps.set_gps(path, 51.5, -0.12)
raw = piexif.load(str(path))["GPS"]
pic = Image.open(path).getexif().get_ifd(ExifTags.IFD.GPSInfo)
assert raw[piexif.GPSIFD.GPSLatitudeRef] == b"N"
assert pic[ExifTags.GPS.GPSLatitudeRef] == "N"
assert pic[ExifTags.GPS.GPSLatitude] == pytest.approx((51.0, 30.0, 0.0), abs=1e-3)
def test_empty_bytes_is_treated_as_a_path():
with pytest.raises(FileNotFoundError):
piexif.load(b"")
def test_existing_tags_survive_set_gps(tmp_path):
path = make_jpeg(tmp_path / "a.jpg")
exif = piexif.load(str(path))
exif["0th"][piexif.ImageIFD.Artist] = b"me"
piexif.insert(piexif.dump(exif), str(path))
gps.set_gps(path, 10.0, 20.0)
assert piexif.load(str(path))["0th"][piexif.ImageIFD.Artist] == b"me"
assert gps.read_gps(path) == (10.0, 20.0)
def test_pillow_save_takes_dumped_bytes(tmp_path):
exif = gps.new_exif()
exif["GPS"] = gps.gps_ifd(48.8584, 2.2945)
path = tmp_path / "b.jpg"
Image.new("RGB", (8, 8)).save(path, "jpeg", exif=piexif.dump(exif))
assert gps.read_gps(path) == pytest.approx((48.8584, 2.2945), abs=1e-3)
pytest.ini
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.