ProvenPin Install

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

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.