Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Writing GPS EXIF information #6657

Closed
cpraschl opened this issue Oct 12, 2022 · 13 comments · Fixed by #6661
Closed

Writing GPS EXIF information #6657

cpraschl opened this issue Oct 12, 2022 · 13 comments · Fixed by #6661

Comments

@cpraschl
Copy link

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

What are your OS, Python and Pillow versions?

  • OS: Windows 10
  • Python: 3.9
  • Pillow: 9.2.0

Sample code

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")
@radarhere radarhere added the Exif label Oct 12, 2022
@radarhere
Copy link
Member

radarhere commented Oct 12, 2022

Could you upload a copy of image_with_exif.JPG? And perhaps 155.jpg for comparison?

@cpraschl
Copy link
Author

cpraschl commented Oct 12, 2022

@radarhere sure:
image_with_exif
155

Also zipped, since I don't know if GitHub pre-processes images that are uploaded:
images.zip

@radarhere
Copy link
Member

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

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?

@cpraschl
Copy link
Author

cpraschl commented Oct 12, 2022

@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

@cpraschl
Copy link
Author

cpraschl commented Oct 12, 2022

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?

{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'}
{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'}
{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}
{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'}

@radarhere
Copy link
Member

Ok, I'll try again, this time just copying all of the XMP data. Does this image work?

155

@cpraschl
Copy link
Author

@radarhere Thanks again. Unfortunately, not. But I am still not sure if GitHub removes Exif data. Could you re-upload it as zip? :)

@radarhere
Copy link
Member

Downloading the image again, I would conclude that it doesn't remove the Exif data, but here you go - 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?

@cpraschl
Copy link
Author

cpraschl commented Oct 13, 2022

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:

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.

@radarhere
Copy link
Member

radarhere commented Oct 13, 2022

I've figured out that Pillow is using the wrong types when saving GPS data to EXIF. It saves GPSLatitude as a double, rather than as a rational. I've created PR #6661 to fix that.

This image tests out this change - out.jpg.zip. Does it work?

@cpraschl
Copy link
Author

@radarhere Thanks a lot for your support and work! The newly generated image is working perfectly fine now :)

@radarhere
Copy link
Member

Excellent. Thanks for your quick response.

So you don't need to make any changes to your original code. If #6661 is merged, then it will be part of the next release. A release is due out is about two weeks, so you may not have to wait long.

If you would like to use the fix before that, you could compile Pillow from source using the branch, but that may not be simple on your Windows machine. Alternatively, if you can find TiffTags.py within your Pillow installation, you can copy the PR's changes by hand.

@cpraschl
Copy link
Author

Thanks :) I have also some Linux machines here, so no problem at all to build it manually for the moment :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants