Repository: python-pillow/Pillow

Issue:
ImageOps.exif_transpose raises a KeyError when removing the EXIF orientation tag
### What did you do?

I used `ImageOps.exif_transpose` on an image with an EXIF orientation tag that should cause a rotation.

### What did you expect to happen?

Get a rotated `Image` with the EXIF orientation removed.

### What actually happened?

```python
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    ImageOps.exif_transpose(img)
  File "/usr/lib/python3.8/site-packages/PIL/ImageOps.py", line 591, in exif_transpose
    del transposed_exif[0x0112]
  File "/usr/lib/python3.8/site-packages/PIL/Image.py", line 3598, in __delitem__
    del self._data[tag]
KeyError: 274
```

### What are your OS, Python and Pillow versions?

* OS: Ubuntu 20.04.2
* Python: 3.8.10
* Pillow: 8.3.0

### Steps to reproduce

```python
from PIL import Image, ImageOps
img = Image.new('L', (200, 200))
# Setting the orientation to rotate 270° clockwise
img.getexif()[0x0112] = 6
# this will cause a KeyError
transposed = ImageOps.exif_transpose(img)
```

### Possible fix

From some quick reading, I believe [this line](https://github.com/python-pillow/Pillow/blob/b057f5f40f1f17efeb5f441453b3274d325a1f0e/src/PIL/ImageOps.py#L590) is the issue. The EXIF tags in a newly transposed image are empty:

```python
>>> dict(img.transpose(Image.ROTATE_270).getexif())
{}
```

But the method attempts to remove the orientation tag from this empty dictionary. It seems the change was made to avoid editing the EXIF tags of the original image (#5546), and using `copy.copy` would probably fix this:

```python
from copy import copy
transposed_exif = copy(exif)
del transposed_exif[0x0112]
print(exif[0x0112])  # it's still there, unmodified
```


Discussion:
Thanks for the detailed analysis. I'll create PR to resolve this shortly, but I would also like to ask - I'm guessing you found this bug by opening an actual image file, right? If you don't want to attach that image, could you let us know the image format and the `info` dictionary? I'm interested in how the EXIF data was stored.

Resolving patch:
diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py
--- a/src/PIL/ImageOps.py
+++ b/src/PIL/ImageOps.py
@@ -19,6 +19,7 @@
 
 import functools
 import operator
+import re
 
 from . import Image, ImageDraw
 
@@ -588,7 +589,19 @@ def exif_transpose(image):
     if method is not None:
         transposed_image = image.transpose(method)
         transposed_exif = transposed_image.getexif()
-        del transposed_exif[0x0112]
-        transposed_image.info["exif"] = transposed_exif.tobytes()
+        if 0x0112 in transposed_exif:
+            del transposed_exif[0x0112]
+            if "exif" in transposed_image.info:
+                transposed_image.info["exif"] = transposed_exif.tobytes()
+            elif "Raw profile type exif" in transposed_image.info:
+                transposed_image.info[
+                    "Raw profile type exif"
+                ] = transposed_exif.tobytes().hex()
+            elif "XML:com.adobe.xmp" in transposed_image.info:
+                transposed_image.info["XML:com.adobe.xmp"] = re.sub(
+                    r'tiff:Orientation="([0-9])"',
+                    "",
+                    transposed_image.info["XML:com.adobe.xmp"],
+                )
         return transposed_image
     return image.copy()


Test patch:
diff --git a/Tests/images/exif_imagemagick_orientation.png b/Tests/images/exif_imagemagick_orientation.png
new file mode 100644
Binary files /dev/null and b/Tests/images/exif_imagemagick_orientation.png differ
diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py
--- a/Tests/test_imageops.py
+++ b/Tests/test_imageops.py
@@ -335,6 +335,28 @@ def check(orientation_im):
                 ) as orientation_im:
                     check(orientation_im)
 
+    # Orientation from "XML:com.adobe.xmp" info key
+    with Image.open("Tests/images/xmp_tags_orientation.png") as im:
+        assert im.getexif()[0x0112] == 3
+
+        transposed_im = ImageOps.exif_transpose(im)
+        assert 0x0112 not in transposed_im.getexif()
+
+    # Orientation from "Raw profile type exif" info key
+    # This test image has been manually hexedited from exif_imagemagick.png
+    # to have a different orientation
+    with Image.open("Tests/images/exif_imagemagick_orientation.png") as im:
+        assert im.getexif()[0x0112] == 3
+
+        transposed_im = ImageOps.exif_transpose(im)
+        assert 0x0112 not in transposed_im.getexif()
+
+    # Orientation set directly on Image.Exif
+    im = hopper()
+    im.getexif()[0x0112] = 3
+    transposed_im = ImageOps.exif_transpose(im)
+    assert 0x0112 not in transposed_im.getexif()
+
 
 def test_autocontrast_cutoff():
     # Test the cutoff argument of autocontrast