Repository: python-pillow/Pillow

Problem statement:
Make EXIF plugin
Exif support (labeled experimental since 2003) is somewhat adequate for reading, but there's no clear way to change a value and then write it out.  We currently support writing Exif as byte strings, with no way to make them apart from manually setting up nested `TiffImagePlugin.ImageFileDirectory` instances, then calling save into a file pointer.
-  `Image.getexif()` should exist, and return an Exif object. 
-  The Exif object should export a dictionary interface.
-  The Exif object should have a `toBytes()` method returning the correct bytes.
-  `Exif.load(bytes).toBytes()` should roundtrip. 
-  This should work: 

```python
exif = Exif()
exif['Orientation'] = 1  
im.save('temp.jpg', exif=exif)
```

No way to read Parallax tag from stereo MPO files
Stereo MPO files (such as those produced with the Fujifilm Finepix W3 camera) store the parallax value in a tag in the EXIF information of the _second_ image in the MPO file, but it appears that Pillow can only read the EXIF information from the first image at present.

The tag can be manually read via exiftool as follows:

``` bash
$ exiftool DSCF3195.MPO -mpimage2 -b > DSCF3195-r.jpg
$ exiftool  DSCF3195-r.jpg -Parallax
Parallax                        : 5.625
```

I have several sample MPO files available:
http://valen.darkstarsword.net/photos/stereo/misc/originals/

I would like to make use of this tag in my Stereo Cropping Tool ( https://github.com/DarkStarSword/StereoCropper ) to start with the correct parallax value.



Hints:
Can the EXIF tags (or any metadata) be preserved when resizing or generating thumbnails?

I wrote a Exif class, that is subclass of dict. Methods "to_bytes" and "load" are added. How is this for prototype?
https://github.com/hMatoba/EXIF

Thanks. I'll take a look, probably after we get 2.6.0 out

I noticed the bump to 2.7.0, so any thought to this bug? 

It didn't get worked on. 

There is some action on dealing with tiff IFDs in #1059, so something may still come of it. (Though, that PR needs to be worked on due to incompatibilities with existing code)

We're now in version 3+, I wonder if this has this been touched?

No, but there have been some underlying changes to the tiff ifd code that may lead to motion.  It certainly needs to be done.
Trying to understand, can anyone explain to me what the purpose of a `toBytes()` method is? I mean, it's not so that it can be passed back into Pillow, since this new class could do that itself, and it's not for humans to read. Is it so that the value can be stored for later use? Is it so that the value can be used in another program?
I've created PR #3625 to resolve this.
> Trying to understand, can anyone explain to me what the purpose of a `toBytes()` method is? I mean, it's not so that it can be passed back into Pillow, since this new class could do that itself, and it's not for humans to read. Is it so that the value can be stored for later use? Is it so that the value can be used in another program?

I would assume being able to load them in another package like `piexif` is what would make the most sense.

Although, I believe you can just do `piexif.load(image.info['exif'])` which bypasses the need for `toBytes()` here, correct?
I can't seem to get the image size of the second frame. Did you ever figure this out?

I'm trying to split mpo files but can't crop the second frame without knowing its size. And doing a seek then save just saves the second frame at the size of the first frame
I haven't looked into it further... it only represents a relatively small enhancement for my tool since my tool is designed to allow the user to very rapidly adjust the parallax and crop on a stereo photo, so not having the parallax pre-set only equates to maybe a second or two of inconvenience per photo... Still, it would be nice to have.
BTW - this is unrelated to Pillow, but I have a shell script that can convert .mpo files into various other formats (including .jps) and correctly accounts for the parallax tag:
https://github.com/DarkStarSword/3d-fixes/blob/master/photo-gallery/orig2all.sh
Are there any images that could be included as part of the test suite, under Pillow's license?
#3625 has commits to resolve this.

A problem here is that the parallax data is stored in the MakerNote tag - this is specific to the manufacturer. So in the PR, I have added reading of the Fujifilm format that you provide here, and the Nintendo format that is already in our test suite. If there are any other formats that you would like supported, please let us know.
> Are there any images that could be included as part of the test suite, under Pillow's license?

Feel free to take your pick from any of these - they're all my own work and I'm more than happy for them to be used under Pillow's license:
http://valen.darkstarsword.net/photos/stereo/misc/originals/
Thanks very much! I've added one of the images to the PR.
Thanks for the image!

However, it's nearly 10 MB, and our current source zip (including most test images) is 16 MB.

Would it be possible to create one which is, say, less than a megabyte?

If not, shall we leave it out of this repo (and the source zip) and put it in https://github.com/python-pillow/pillow-depends/tree/master/test_images instead, and pull it in just for testing?
Just for the record - while a natural image is obviously better, another option would be to switch back to original 117kb file from my original commits in the PR. I copied one of the existing test images and then manually hexedited in the parallax data.

Patch:
diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py
--- a/src/PIL/ImageFile.py
+++ b/src/PIL/ImageFile.py
@@ -27,11 +27,20 @@
 # See the README file for information on usage and redistribution.
 #
 
-from . import Image
-from ._util import isPath
+from . import Image, TiffTags
+from ._binary import i32le
+from ._util import isPath, py3
 import io
 import sys
 import struct
+import warnings
+
+try:
+    # Python 3
+    from collections.abc import MutableMapping
+except ImportError:
+    # Python 2.7
+    from collections import MutableMapping
 
 MAXBLOCK = 65536
 
@@ -293,6 +302,12 @@ def _seek_check(self, frame):
 
         return self.tell() != frame
 
+    def getexif(self):
+        exif = Exif()
+        if "exif" in self.info:
+            exif.load(self.info["exif"])
+        return exif
+
 
 class StubImageFile(ImageFile):
     """
@@ -672,3 +687,182 @@ def set_as_raw(self, data, rawmode=None):
             raise ValueError("not enough image data")
         if s[1] != 0:
             raise ValueError("cannot decode image data")
+
+
+class Exif(MutableMapping):
+    endian = "<"
+
+    def __init__(self):
+        self._data = {}
+        self._ifds = {}
+
+    def _fixup_dict(self, src_dict):
+        # Helper function for _getexif()
+        # returns a dict with any single item tuples/lists as individual values
+        def _fixup(value):
+            try:
+                if len(value) == 1 and not isinstance(value, dict):
+                    return value[0]
+            except Exception:
+                pass
+            return value
+
+        return {k: _fixup(v) for k, v in src_dict.items()}
+
+    def _get_ifd_dict(self, tag):
+        try:
+            # an offset pointer to the location of the nested embedded IFD.
+            # It should be a long, but may be corrupted.
+            self.fp.seek(self._data[tag])
+        except (KeyError, TypeError):
+            pass
+        else:
+            from . import TiffImagePlugin
+            info = TiffImagePlugin.ImageFileDirectory_v1(self.head)
+            info.load(self.fp)
+            return self._fixup_dict(info)
+
+    def load(self, data):
+        # Extract EXIF information.  This is highly experimental,
+        # and is likely to be replaced with something better in a future
+        # version.
+
+        # The EXIF record consists of a TIFF file embedded in a JPEG
+        # application marker (!).
+        self.fp = io.BytesIO(data[6:])
+        self.head = self.fp.read(8)
+        # process dictionary
+        from . import TiffImagePlugin
+        info = TiffImagePlugin.ImageFileDirectory_v1(self.head)
+        self.endian = info._endian
+        self.fp.seek(info.next)
+        info.load(self.fp)
+        self._data = dict(self._fixup_dict(info))
+
+        # get EXIF extension
+        ifd = self._get_ifd_dict(0x8769)
+        if ifd:
+            self._data.update(ifd)
+            self._ifds[0x8769] = ifd
+
+        # get gpsinfo extension
+        ifd = self._get_ifd_dict(0x8825)
+        if ifd:
+            self._data[0x8825] = ifd
+            self._ifds[0x8825] = ifd
+
+    def tobytes(self, offset=0):
+        from . import TiffImagePlugin
+        if self.endian == "<":
+            head = b"II\x2A\x00\x08\x00\x00\x00"
+        else:
+            head = b"MM\x00\x2A\x00\x00\x00\x08"
+        ifd = TiffImagePlugin.ImageFileDirectory_v2(ifh=head)
+        for tag, value in self._data.items():
+            ifd[tag] = value
+        return b"Exif\x00\x00"+head+ifd.tobytes(offset)
+
+    def get_ifd(self, tag):
+        if tag not in self._ifds and tag in self._data:
+            if tag == 0xa005:  # interop
+                self._ifds[tag] = self._get_ifd_dict(tag)
+            elif tag == 0x927c:  # makernote
+                from . import TiffImagePlugin
+                if self._data[0x927c][:8] == b"FUJIFILM":
+                    exif_data = self._data[0x927c]
+                    ifd_offset = i32le(exif_data[8:12])
+                    ifd_data = exif_data[ifd_offset:]
+
+                    makernote = {}
+                    for i in range(0, struct.unpack("<H", ifd_data[:2])[0]):
+                        ifd_tag, typ, count, data = struct.unpack(
+                            "<HHL4s", ifd_data[i*12 + 2:(i+1)*12 + 2])
+                        try:
+                            unit_size, handler =\
+                                TiffImagePlugin.ImageFileDirectory_v2._load_dispatch[
+                                    typ
+                                ]
+                        except KeyError:
+                            continue
+                        size = count * unit_size
+                        if size > 4:
+                            offset, = struct.unpack("<L", data)
+                            data = ifd_data[offset-12:offset+size-12]
+                        else:
+                            data = data[:size]
+
+                        if len(data) != size:
+                            warnings.warn("Possibly corrupt EXIF MakerNote data.  "
+                                          "Expecting to read %d bytes but only got %d."
+                                          " Skipping tag %s"
+                                          % (size, len(data), ifd_tag))
+                            continue
+
+                        if not data:
+                            continue
+
+                        makernote[ifd_tag] = handler(
+                            TiffImagePlugin.ImageFileDirectory_v2(), data, False)
+                    self._ifds[0x927c] = dict(self._fixup_dict(makernote))
+                elif self._data.get(0x010f) == "Nintendo":
+                    ifd_data = self._data[0x927c]
+
+                    makernote = {}
+                    for i in range(0, struct.unpack(">H", ifd_data[:2])[0]):
+                        ifd_tag, typ, count, data = struct.unpack(
+                            ">HHL4s", ifd_data[i*12 + 2:(i+1)*12 + 2])
+                        if ifd_tag == 0x1101:
+                            # CameraInfo
+                            offset, = struct.unpack(">L", data)
+                            self.fp.seek(offset)
+
+                            camerainfo = {'ModelID': self.fp.read(4)}
+
+                            self.fp.read(4)
+                            # Seconds since 2000
+                            camerainfo['TimeStamp'] = i32le(self.fp.read(12))
+
+                            self.fp.read(4)
+                            camerainfo['InternalSerialNumber'] = self.fp.read(4)
+
+                            self.fp.read(12)
+                            parallax = self.fp.read(4)
+                            handler =\
+                                TiffImagePlugin.ImageFileDirectory_v2._load_dispatch[
+                                    TiffTags.FLOAT
+                                ][1]
+                            camerainfo['Parallax'] = handler(
+                                TiffImagePlugin.ImageFileDirectory_v2(),
+                                parallax, False)
+
+                            self.fp.read(4)
+                            camerainfo['Category'] = self.fp.read(2)
+
+                            makernote = {0x1101: dict(self._fixup_dict(camerainfo))}
+                    self._ifds[0x927c] = makernote
+        return self._ifds.get(tag, {})
+
+    def __str__(self):
+        return str(self._data)
+
+    def __len__(self):
+        return len(self._data)
+
+    def __getitem__(self, tag):
+        return self._data[tag]
+
+    def __contains__(self, tag):
+        return tag in self._data
+
+    if not py3:
+        def has_key(self, tag):
+            return tag in self
+
+    def __setitem__(self, tag, value):
+        self._data[tag] = value
+
+    def __delitem__(self, tag):
+        del self._data[tag]
+
+    def __iter__(self):
+        return iter(set(self._data))
diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py
--- a/src/PIL/JpegImagePlugin.py
+++ b/src/PIL/JpegImagePlugin.py
@@ -86,7 +86,7 @@ def APP(self, marker):
             self.info["jfif_density"] = jfif_density
     elif marker == 0xFFE1 and s[:5] == b"Exif\0":
         if "exif" not in self.info:
-            # extract Exif information (incomplete)
+            # extract EXIF information (incomplete)
             self.info["exif"] = s  # FIXME: value will change
     elif marker == 0xFFE2 and s[:5] == b"FPXR\0":
         # extract FlashPix information (incomplete)
@@ -168,7 +168,7 @@ def APP(self, marker):
                 dpi *= 2.54
             self.info["dpi"] = dpi, dpi
         except (KeyError, SyntaxError, ZeroDivisionError):
-            # SyntaxError for invalid/unreadable exif
+            # SyntaxError for invalid/unreadable EXIF
             # KeyError for dpi not included
             # ZeroDivisionError for invalid dpi rational value
             self.info["dpi"] = 72, 72
@@ -472,65 +472,20 @@ def _getmp(self):
 def _fixup_dict(src_dict):
     # Helper function for _getexif()
     # returns a dict with any single item tuples/lists as individual values
-    def _fixup(value):
-        try:
-            if len(value) == 1 and not isinstance(value, dict):
-                return value[0]
-        except Exception:
-            pass
-        return value
-
-    return {k: _fixup(v) for k, v in src_dict.items()}
+    exif = ImageFile.Exif()
+    return exif._fixup_dict(src_dict)
 
 
 def _getexif(self):
-    # Extract EXIF information.  This method is highly experimental,
-    # and is likely to be replaced with something better in a future
-    # version.
-
     # Use the cached version if possible
     try:
         return self.info["parsed_exif"]
     except KeyError:
         pass
 
-    # The EXIF record consists of a TIFF file embedded in a JPEG
-    # application marker (!).
-    try:
-        data = self.info["exif"]
-    except KeyError:
+    if "exif" not in self.info:
         return None
-    fp = io.BytesIO(data[6:])
-    head = fp.read(8)
-    # process dictionary
-    info = TiffImagePlugin.ImageFileDirectory_v1(head)
-    fp.seek(info.next)
-    info.load(fp)
-    exif = dict(_fixup_dict(info))
-    # get exif extension
-    try:
-        # exif field 0x8769 is an offset pointer to the location
-        # of the nested embedded exif ifd.
-        # It should be a long, but may be corrupted.
-        fp.seek(exif[0x8769])
-    except (KeyError, TypeError):
-        pass
-    else:
-        info = TiffImagePlugin.ImageFileDirectory_v1(head)
-        info.load(fp)
-        exif.update(_fixup_dict(info))
-    # get gpsinfo extension
-    try:
-        # exif field 0x8825 is an offset pointer to the location
-        # of the nested embedded gps exif ifd.
-        # It should be a long, but may be corrupted.
-        fp.seek(exif[0x8825])
-    except (KeyError, TypeError):
-        pass
-    else:
-        info = TiffImagePlugin.ImageFileDirectory_v1(head)
-        info.load(fp)
-        exif[0x8825] = _fixup_dict(info)
+    exif = dict(self.getexif())
 
     # Cache the result for future use
     self.info["parsed_exif"] = exif
@@ -769,6 +724,10 @@ def validate_qtables(qtables):
 
     optimize = info.get("optimize", False)
 
+    exif = info.get("exif", b"")
+    if isinstance(exif, ImageFile.Exif):
+        exif = exif.tobytes()
+
     # get keyword arguments
     im.encoderconfig = (
         quality,
@@ -780,7 +739,7 @@ def validate_qtables(qtables):
         subsampling,
         qtables,
         extra,
-        info.get("exif", b"")
+        exif
         )
 
     # if we optimize, libjpeg needs a buffer big enough to hold the whole image
@@ -798,9 +757,9 @@ def validate_qtables(qtables):
         else:
             bufsize = im.size[0] * im.size[1]
 
-    # The exif info needs to be written as one block, + APP1, + one spare byte.
+    # The EXIF info needs to be written as one block, + APP1, + one spare byte.
     # Ensure that our buffer is big enough. Same with the icc_profile block.
-    bufsize = max(ImageFile.MAXBLOCK, bufsize, len(info.get("exif", b"")) + 5,
+    bufsize = max(ImageFile.MAXBLOCK, bufsize, len(exif) + 5,
                   len(extra) + 1)
 
     ImageFile._save(im, fp, [("jpeg", (0, 0)+im.size, 0, rawmode)], bufsize)
diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py
--- a/src/PIL/PngImagePlugin.py
+++ b/src/PIL/PngImagePlugin.py
@@ -696,8 +696,14 @@ def load_end(self):
     def _getexif(self):
         if "exif" not in self.info:
             self.load()
-        from .JpegImagePlugin import _getexif
-        return _getexif(self)
+        if "exif" not in self.info:
+            return None
+        return dict(self.getexif())
+
+    def getexif(self):
+        if "exif" not in self.info:
+            self.load()
+        return ImageFile.ImageFile.getexif(self)
 
 
 # --------------------------------------------------------------------
@@ -880,6 +886,8 @@ def _save(im, fp, filename, chunk=putchunk):
 
     exif = im.encoderinfo.get("exif", im.info.get("exif"))
     if exif:
+        if isinstance(exif, ImageFile.Exif):
+            exif = exif.tobytes(8)
         if exif.startswith(b"Exif\x00\x00"):
             exif = exif[6:]
         chunk(fp, b"eXIf", exif)
diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py
--- a/src/PIL/TiffImagePlugin.py
+++ b/src/PIL/TiffImagePlugin.py
@@ -785,17 +785,12 @@ def load(self, fp):
             warnings.warn(str(msg))
             return
 
-    def save(self, fp):
-
-        if fp.tell() == 0:  # skip TIFF header on subsequent pages
-            # tiff header -- PIL always starts the first IFD at offset 8
-            fp.write(self._prefix + self._pack("HL", 42, 8))
-
+    def tobytes(self, offset=0):
         # FIXME What about tagdata?
-        fp.write(self._pack("H", len(self._tags_v2)))
+        result = self._pack("H", len(self._tags_v2))
 
         entries = []
-        offset = fp.tell() + len(self._tags_v2) * 12 + 4
+        offset = offset + len(result) + len(self._tags_v2) * 12 + 4
         stripoffsets = None
 
         # pass 1: convert tags to binary format
@@ -844,18 +839,29 @@ def save(self, fp):
         for tag, typ, count, value, data in entries:
             if DEBUG > 1:
                 print(tag, typ, count, repr(value), repr(data))
-            fp.write(self._pack("HHL4s", tag, typ, count, value))
+            result += self._pack("HHL4s", tag, typ, count, value)
 
         # -- overwrite here for multi-page --
-        fp.write(b"\0\0\0\0")  # end of entries
+        result += b"\0\0\0\0"  # end of entries
 
         # pass 3: write auxiliary data to file
         for tag, typ, count, value, data in entries:
-            fp.write(data)
+            result += data
             if len(data) & 1:
-                fp.write(b"\0")
+                result += b"\0"
+
+        return result
+
+    def save(self, fp):
+
+        if fp.tell() == 0:  # skip TIFF header on subsequent pages
+            # tiff header -- PIL always starts the first IFD at offset 8
+            fp.write(self._prefix + self._pack("HL", 42, 8))
 
-        return offset
+        offset = fp.tell()
+        result = self.tobytes(offset)
+        fp.write(result)
+        return offset + len(result)
 
 
 ImageFileDirectory_v2._load_dispatch = _load_dispatch
diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py
--- a/src/PIL/WebPImagePlugin.py
+++ b/src/PIL/WebPImagePlugin.py
@@ -93,8 +93,9 @@ def _open(self):
         self.seek(0)
 
     def _getexif(self):
-        from .JpegImagePlugin import _getexif
-        return _getexif(self)
+        if "exif" not in self.info:
+            return None
+        return dict(self.getexif())
 
     @property
     def n_frames(self):
@@ -216,6 +217,8 @@ def _save_all(im, fp, filename):
     method = im.encoderinfo.get("method", 0)
     icc_profile = im.encoderinfo.get("icc_profile", "")
     exif = im.encoderinfo.get("exif", "")
+    if isinstance(exif, ImageFile.Exif):
+        exif = exif.tobytes()
     xmp = im.encoderinfo.get("xmp", "")
     if allow_mixed:
         lossless = False
@@ -315,6 +318,8 @@ def _save(im, fp, filename):
     quality = im.encoderinfo.get("quality", 80)
     icc_profile = im.encoderinfo.get("icc_profile", "")
     exif = im.encoderinfo.get("exif", "")
+    if isinstance(exif, ImageFile.Exif):
+        exif = exif.tobytes()
     xmp = im.encoderinfo.get("xmp", "")
 
     if im.mode not in _VALID_WEBP_LEGACY_MODES:


Test patch:
diff --git a/Tests/images/fujifilm.mpo b/Tests/images/fujifilm.mpo
new file mode 100644
Binary files /dev/null and b/Tests/images/fujifilm.mpo differ
diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py
--- a/Tests/test_file_mpo.py
+++ b/Tests/test_file_mpo.py
@@ -64,6 +64,18 @@ def test_frame_size(self):
         im.seek(1)
         self.assertEqual(im.size, (680, 480))
 
+    def test_parallax(self):
+        # Nintendo
+        im = Image.open("Tests/images/sugarshack.mpo")
+        exif = im.getexif()
+        self.assertEqual(exif.get_ifd(0x927c)[0x1101]["Parallax"], -44.798187255859375)
+
+        # Fujifilm
+        im = Image.open("Tests/images/fujifilm.mpo")
+        im.seek(1)
+        exif = im.getexif()
+        self.assertEqual(exif.get_ifd(0x927c)[0xb211], -3.125)
+
     def test_mp(self):
         for test_file in test_files:
             im = Image.open(test_file)
diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py
--- a/Tests/test_imagefile.py
+++ b/Tests/test_imagefile.py
@@ -1,4 +1,4 @@
-from .helper import PillowTestCase, hopper, fromstring, tostring
+from .helper import unittest, PillowTestCase, hopper, fromstring, tostring
 
 from io import BytesIO
 
@@ -6,6 +6,12 @@
 from PIL import ImageFile
 from PIL import EpsImagePlugin
 
+try:
+    from PIL import _webp
+    HAVE_WEBP = True
+except ImportError:
+    HAVE_WEBP = False
+
 
 codecs = dir(Image.core)
 
@@ -233,3 +239,97 @@ def test_no_format(self):
         im = MockImageFile(buf)
         self.assertIsNone(im.format)
         self.assertIsNone(im.get_format_mimetype())
+
+    def test_exif_jpeg(self):
+        im = Image.open("Tests/images/exif-72dpi-int.jpg")  # Little endian
+        exif = im.getexif()
+        self.assertNotIn(258, exif)
+        self.assertIn(40960, exif)
+        self.assertEqual(exif[40963], 450)
+        self.assertEqual(exif[11], "gThumb 3.0.1")
+
+        out = self.tempfile('temp.jpg')
+        exif[258] = 8
+        del exif[40960]
+        exif[40963] = 455
+        exif[11] = "Pillow test"
+        im.save(out, exif=exif)
+        reloaded = Image.open(out)
+        reloaded_exif = reloaded.getexif()
+        self.assertEqual(reloaded_exif[258], 8)
+        self.assertNotIn(40960, exif)
+        self.assertEqual(reloaded_exif[40963], 455)
+        self.assertEqual(exif[11], "Pillow test")
+
+        im = Image.open("Tests/images/no-dpi-in-exif.jpg")  # Big endian
+        exif = im.getexif()
+        self.assertNotIn(258, exif)
+        self.assertIn(40962, exif)
+        self.assertEqual(exif[40963], 200)
+        self.assertEqual(exif[305], "Adobe Photoshop CC 2017 (Macintosh)")
+
+        out = self.tempfile('temp.jpg')
+        exif[258] = 8
+        del exif[34665]
+        exif[40963] = 455
+        exif[305] = "Pillow test"
+        im.save(out, exif=exif)
+        reloaded = Image.open(out)
+        reloaded_exif = reloaded.getexif()
+        self.assertEqual(reloaded_exif[258], 8)
+        self.assertNotIn(40960, exif)
+        self.assertEqual(reloaded_exif[40963], 455)
+        self.assertEqual(exif[305], "Pillow test")
+
+    @unittest.skipIf(not HAVE_WEBP or not _webp.HAVE_WEBPANIM,
+                     "WebP support not installed with animation")
+    def test_exif_webp(self):
+        im = Image.open("Tests/images/hopper.webp")
+        exif = im.getexif()
+        self.assertEqual(exif, {})
+
+        out = self.tempfile('temp.webp')
+        exif[258] = 8
+        exif[40963] = 455
+        exif[305] = "Pillow test"
+
+        def check_exif():
+            reloaded = Image.open(out)
+            reloaded_exif = reloaded.getexif()
+            self.assertEqual(reloaded_exif[258], 8)
+            self.assertEqual(reloaded_exif[40963], 455)
+            self.assertEqual(exif[305], "Pillow test")
+        im.save(out, exif=exif)
+        check_exif()
+        im.save(out, exif=exif, save_all=True)
+        check_exif()
+
+    def test_exif_png(self):
+        im = Image.open("Tests/images/exif.png")
+        exif = im.getexif()
+        self.assertEqual(exif, {274: 1})
+
+        out = self.tempfile('temp.png')
+        exif[258] = 8
+        del exif[274]
+        exif[40963] = 455
+        exif[305] = "Pillow test"
+        im.save(out, exif=exif)
+
+        reloaded = Image.open(out)
+        reloaded_exif = reloaded.getexif()
+        self.assertEqual(reloaded_exif, {
+            258: 8,
+            40963: 455,
+            305: 'Pillow test',
+        })
+
+    def test_exif_interop(self):
+        im = Image.open("Tests/images/flower.jpg")
+        exif = im.getexif()
+        self.assertEqual(exif.get_ifd(0xa005), {
+            1: 'R98',
+            2: b'0100',
+            4097: 2272,
+            4098: 1704,
+        })