Repository: python-pillow/Pillow

Issue:
Writing GPS EXIF information
### What did you do?

I am trying to geo-reference images and for this I want to add GPS Exif data to the images.
While reading and writing using Pillow seems to work fine, the written GPS Exif Data seems to be invalid and is not accessible in other programs such as the Windows File Property overview.

### What did you expect to happen?

When I write GPS Exif that it is also readable for other tools.

### What actually happened?

GPS Exif is not available in other programs such as Windows File Properties (left image created with Pillow; right image created with camera) :
![image](https://user-images.githubusercontent.com/38362068/195270766-4a148c40-724b-4ec3-8f4f-af07d4ccf4cb.png)


### What are your OS, Python and Pillow versions?

* OS: Windows 10
* Python: 3.9
* Pillow: 9.2.0

### Sample code

```python
import math
from typing import Tuple

from PIL import Image
from PIL.ExifTags import GPSTAGS

def truncate(f: float, n: int = 0) -> float:
    """
    Method for truncating a float value to n digits without rounding
    :param f: to be truncated
    :param n: number of digits
    :return: truncated float
    """
    return math.floor(f * 10 ** n) / 10 ** n

def get_dms_from_decimal(decimal: float) -> Tuple[float, float, float]:
    """
    Convert decimal value to DMS (degrees, minutes, seconds) tuple
    :param decimal: to be converted
    :return:
    """
    degrees = truncate(decimal, 0)
    minutes_whole = (decimal - degrees) * 60
    minutes = truncate(minutes_whole, 0)
    seconds = (minutes_whole - minutes) * 60
    return degrees, minutes, seconds


def get_decimal_from_dms(dms: Tuple[float, float, float], ref: str = "N") -> float:
    """
    Method for converting a DMS (degrees, minutes, seconds) tuple to a decimal value
    :param dms: to be converted
    :param ref: compass direction (N, E, S, W)
    :return: decimal representation
    """
    degrees = dms[0]
    minutes = dms[1] / 60.0
    seconds = dms[2] / 3600.0

    if ref in ['S', 'W']:
        degrees = -degrees
        minutes = -minutes
        seconds = -seconds

    return degrees + minutes + seconds


class GpsExifWriter:
    """
    Class allowing to write GPS exif data to an image
    """

    def get_gps(self, image_path: str) -> Tuple[float, float, float]:
        """
        Read gps EXIF information form image
        :param image_path: from which EXIF should be read
        :return: (lat, lng, alt) tuple
        """
        image = Image.open(image_path)
        exif = image.getexif()
        gps_info = exif.get_ifd(34853)
        gps_exif = {
            GPSTAGS.get(key, key): value
            for key, value in gps_info.items()
        }
        gps_latitude = gps_exif.get("GPSLatitude")
        gps_longitude = gps_exif.get("GPSLongitude")
        if gps_longitude is None or gps_latitude is None:
            raise Exception("No GPS information available in image")
        gps_latitude_ref = gps_exif.get("GPSLatitudeRef") or "N"
        gps_longitude_ref = gps_exif.get("GPSLongitudeRef") or "E"

        lat = get_decimal_from_dms(gps_latitude, gps_latitude_ref)
        lng = get_decimal_from_dms(gps_longitude, gps_longitude_ref)
        alt = gps_exif.get("GPSAltitude") or 0

        return lat, lng, alt

    def write_gps(self, image_path: str, lng: float, lat: float, alt: float, camera_manufacturer: str, camera: str, ) -> None:
        """
        Method for writing GPS exif information to an image
        :param image_path: to which exif should be added
        :param lng: longitude
        :param lat: latitude
        :param alt: altitude
        :param camera_manufacturer: Manufacturer of the camera
        :param camera: Camera used
        :return: None
        """
        image = Image.open(image_path)
        exif = image.getexif()
        gps_value = {0: b'\x02\x03\x00\x00', 1: 'N', 2: get_dms_from_decimal(lat), 3: 'E', 4: get_dms_from_decimal(lng), 5: b'\x00', 6: alt, 9: 'A', 18: 'WGS-84\x00'}
        # TODO writing probably not 100% correct: Reading of written data is possible, but e.g. windows explorer does not show up GPS information
        exif[34853] = gps_value
        exif[271] = camera_manufacturer
        exif[272] = camera
        image.save(image_path, exif=exif)


if __name__ == '__main__':
    image_path = r"C:\public\some_image.jpg"
    writer = GpsExifWriter()
    lat = 48.370075472222226
    lng = 14.5132905
    alt = 563.874
    writer.write_gps(image_path, lng, lat, alt, "Manufacturer", "Camera")

    gps = writer.get_gps(image_path)

    if lat == gps[0] and lng == gps[1] and alt == gps[2]:
        print("Re-Read successful")
    else:
        print("Differences in read and written GPS")

```



Discussion:
Could you upload a copy of image_with_exif.JPG? And perhaps 155.jpg for comparison?
@radarhere sure:
![image_with_exif](https://user-images.githubusercontent.com/38362068/195273875-c2e1af83-61be-4d58-80f2-c4350c14bef8.JPG)
![155](https://user-images.githubusercontent.com/38362068/195273883-0be87793-d943-45fa-83c1-3ba9c1ca300a.jpg)

Also zipped, since I don't know if GitHub pre-processes images that are uploaded:
[images.zip](https://github.com/python-pillow/Pillow/files/9762446/images.zip)



Inspecting image_with_exif.JPG with exiftool, I see that the GPS data is not just present within the EXIF IFD, it is also written as XMP data.

So, I've tried modifying your code to also write the GPS data as XMP. The following is the code I inserted, and you can see the full version and the generated image here - [xmp.zip](https://github.com/python-pillow/Pillow/files/9763334/xmp.zip)

```python
import struct
data = b'http://ns.adobe.com/xap/1.0/\x00<?xpacket begin="?" id="W5M0MpCehiHzreSzNTczkc9d"?>\n<x:xmpmeta xmlns:x="adobe:ns:meta/">\n<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">\n<rdf:Description exif:GpsLatitude="%f"\nexif:GpsLongitude="%f"\nexif:AbsoluteAltitude="%f">\n</rdf:Description>\n</rdf:RDF>\n</x:xmpmeta>\n<?xpacket end="w"?>\n' % (lat, lng, alt)
size = struct.pack(">H", 2 + len(data))
app1 = b"\xFF\xE1" + size + data
image.save(image_path, exif=exif, extra=app1)
```

However, I don't have a Windows machine to check. Does it detect the GPS information from the file now?
@radarhere Thanks a lot for your help! Unfortunately, the GPS data is still not available. Neither in the provided image from the zip file nor, when I am adapting my source code based on your additions.

![image](https://user-images.githubusercontent.com/38362068/195301862-f32b717c-67da-47a5-af0f-01f43cda6428.png)

Comparing the exif data, respectively the GPS IFD information, from the originally geo-referenced image (first and second json) and the manually one using Pillow (third and fourth json), I don't see big differences except of the XMP data and some additional (hopefully optional) properties?

```json
{296: 2, 282: 72.0, 34853: 590, 34665: 260, 270: 'WhiteHot', 271: 'DJI', 272: 'M30T', 305: '10.00.38.17', 274: 1, 306: '2022:10:05 10:08:42', 531: 1, 283: 72.0, 40092: b'0\x00.\x009\x00.\x001\x004\x002\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 40094: b's\x00i\x00n\x00g\x00l\x00e\x00'}
```

```json
{0: b'\x02\x03\x00\x00', 1: 'N', 2: (48.0, 22.0, 12.2717), 3: 'E', 4: (14.0, 30.0, 47.8458), 5: b'\x00', 6: 563.874, 9: 'A', 18: 'WGS-84\x00'}
```

```json
{296: 2, 282: 72.0, 34853: 344, 256: 1920, 257: 1080, 258: (8, 8, 8), 262: 2, 34665: 290, 271: 'Manufacturer', 272: 'Camera', 305: 'Adobe Photoshop 22.1 (Windows)', 274: 1, 306: '2022:10:12 08:54:47', 277: 3, 283: 72.0}
```

```json
{0: b'\x02\x03\x00\x00', 1: 'N', 2: (48.0, 22.0, 12.271700000012515), 3: 'E', 4: (14.0, 30.0, 47.84580000000034), 5: b'\x00', 6: 563.874, 9: 'A', 18: 'WGS-84\x00'}
```

Ok, I'll try again, this time just copying all of the XMP data. Does this image work?

![155](https://user-images.githubusercontent.com/3112309/195315426-532c1833-a454-4abd-91db-1f2a34dace72.jpg)
@radarhere Thanks again. Unfortunately, not. But I am still not sure if GitHub removes Exif data. Could you re-upload it as zip? :)
Downloading the image again, I would conclude that it doesn't remove the Exif data, but here you go - [155.jpg.zip](https://github.com/python-pillow/Pillow/files/9768588/155.jpg.zip)

> the written GPS Exif Data seems to be invalid and is not accessible in other programs such as the Windows File Property overview.

So there are other programs apart from Windows File Property that are showing you this difference between the two images? What are they?
Hi @radarhere,

I also tried online tools like https://www.pic2map.com/. Here I managed to find out in the meantime, that it won't show the geo-referenced photos if no camera manufacturer or camera model is added within the EXIF data.

For other tools, I have not figured out why the images created with Pillow are not working but the real geo-referenced images are working like:
- https://www.freemaptools.com/view-and-edit-photo-gps-data.htm 
- https://www.photo-location.net/
- [QGis](https://www.qgis.org/en/site/) (more or less the gold standard software for geographic information processing) and its ImportPhotos plugin 


Google Earth Pro is also struggling, but only with the altitude information. But this is also true with the real geo-referenced images, so it is probably a problem of Google Earth itself.


Resolving patch:
diff --git a/src/PIL/TiffTags.py b/src/PIL/TiffTags.py
--- a/src/PIL/TiffTags.py
+++ b/src/PIL/TiffTags.py
@@ -232,7 +232,39 @@ def lookup(tag, group=None):
         41730: ("CFAPattern", UNDEFINED, 1),
     },
     # GPSInfoIFD
-    34853: {},
+    34853: {
+        0: ("GPSVersionID", BYTE, 4),
+        1: ("GPSLatitudeRef", ASCII, 2),
+        2: ("GPSLatitude", RATIONAL, 3),
+        3: ("GPSLongitudeRef", ASCII, 2),
+        4: ("GPSLongitude", RATIONAL, 3),
+        5: ("GPSAltitudeRef", BYTE, 1),
+        6: ("GPSAltitude", RATIONAL, 1),
+        7: ("GPSTimeStamp", RATIONAL, 3),
+        8: ("GPSSatellites", ASCII, 0),
+        9: ("GPSStatus", ASCII, 2),
+        10: ("GPSMeasureMode", ASCII, 2),
+        11: ("GPSDOP", RATIONAL, 1),
+        12: ("GPSSpeedRef", ASCII, 2),
+        13: ("GPSSpeed", RATIONAL, 1),
+        14: ("GPSTrackRef", ASCII, 2),
+        15: ("GPSTrack", RATIONAL, 1),
+        16: ("GPSImgDirectionRef", ASCII, 2),
+        17: ("GPSImgDirection", RATIONAL, 1),
+        18: ("GPSMapDatum", ASCII, 0),
+        19: ("GPSDestLatitudeRef", ASCII, 2),
+        20: ("GPSDestLatitude", RATIONAL, 3),
+        21: ("GPSDestLongitudeRef", ASCII, 2),
+        22: ("GPSDestLongitude", RATIONAL, 3),
+        23: ("GPSDestBearingRef", ASCII, 2),
+        24: ("GPSDestBearing", RATIONAL, 1),
+        25: ("GPSDestDistanceRef", ASCII, 2),
+        26: ("GPSDestDistance", RATIONAL, 1),
+        27: ("GPSProcessingMethod", UNDEFINED, 0),
+        28: ("GPSAreaInformation", UNDEFINED, 0),
+        29: ("GPSDateStamp", ASCII, 11),
+        30: ("GPSDifferential", SHORT, 1),
+    },
     # InteroperabilityIFD
     40965: {1: ("InteropIndex", ASCII, 1), 2: ("InteropVersion", UNDEFINED, 1)},
 }


Test patch: