Repository: python-pillow/Pillow

Issue:
Add a switch to control whether image.copy() or not in ImageOps.exif_transpose
<!--
Thank you for reporting an issue.

Follow these guidelines to ensure your issue is handled properly.

If you have a ...

1.  General question: consider asking the question on Stack Overflow
    with the python-imaging-library tag:

    * https://stackoverflow.com/questions/tagged/python-imaging-library

    Do not ask a question in both places.

    If you think you have found a bug or have an unexplained exception
    then file a bug report here.

2.  Bug report: include a self-contained, copy-pastable example that
    generates the issue if possible. Be concise with code posted.
    Guidelines on how to provide a good bug report:

    * https://stackoverflow.com/help/mcve

    Bug reports which follow these guidelines are easier to diagnose,
    and are often handled much more quickly.

3.  Feature request: do a quick search of existing issues
    to make sure this has not been asked before.

We know asking good questions takes effort, and we appreciate your time.
Thank you.
-->

### What did you expect to happen?
* Python: 3.9+
* Pillow: 9.5

Add a switch flag to control whether `image.copy()` or not if _no orientation_ or _orientation=1_ in exif to the experimental function `ImageOps.exif_transpose`.

As pointed out in https://github.com/python-pillow/Pillow/pull/5574#issuecomment-872899719 , pillow wants to _keeps the output consistent_ .
However there's unnecessary computation&time cost, and even in many cases where the `copy()` is not needed, for example:
```python
# Just open image with correct look as human eyes'

from PIL import Image
im = Image.open("hopper.jpg")
im = ImageOps.exif_transpose(im)  #assign to the same variable

#or simplified by:
im = ImageOps.exif_transpose(Image.open("hopper.jpg"))
```


It's better to let the user decide explicitly when he/she knows what's happening under the hood and wants more control. For lazy user just keep the old consistent manner.

the code maybe like this or something similar:
```python
# Defined in ImageOps.py
def exif_transpose(image, copy_when_no_transpose=True):
    # check orientation in exif
    if method is not None:
        # do something
        return transposed_image
    else:
        return image.copy() if copy_when_no_transpose else image


----------------------------------------

# Called in user code
im = exif_transpose(im, copy_when_no_transpose=False)
```




<!--
Please include **code** that reproduces the issue and whenever possible, an **image** that demonstrates the issue. Please upload images to GitHub, not to third-party file hosting sites. If necessary, add the image to a zip or tar archive.

The best reproductions are self-contained scripts with minimal dependencies. If you are using a framework such as Plone, Django, or Buildout, try to replicate the issue just using Pillow.
-->




Discussion:
Here's a function I wrote for myself to do this:

```python
from PIL import ExifTags, ImageOps

def apply_exif_orientation(img):
    orientation = img.getexif().get(ExifTags.Base.Orientation)
    if orientation and 2 <= orientation <= 8:
        return ImageOps.exif_transpose(img)
    return img
```
Your code certainly works. That is the workaround now for there's no `copy()` switch in the official function.

But read and check exif twice in both **user code** and `ImageOps.exif_transpose`, with additional importing ExifTags and defining a function separately which also requires knowledge of exif, is not as simple as just one switch flag.
> ```python
> from PIL import Image
> im = Image.open("hopper.jpg")
> im = ImageOps.exif_transpose(im)  #assign to the same variable
> 
> #or simplified by:
> im = ImageOps.exif_transpose(Image.open("hopper.jpg"))
> ```

The first example here doesn't feel like an intuitive API to me.
The second example potentially loses the reference to the original image, which means [you can't close the underlying file](https://pillow.readthedocs.io/en/stable/releasenotes/7.0.0.html#image-del) (this also applies to the first example, but you could wrap it in a context manager to avoid that).

If we wanted to do this, I think a better solution would be to follow the example of [`ImageCms.applyTransform()`](https://pillow.readthedocs.io/en/stable/reference/ImageCms.html#PIL.ImageCms.applyTransform), and add an `inPlace` argument, defaulting to `False`, that if set to `True`, modifies the image in place and doesn't return anything.
> The second example potentially loses the reference to the original image, which means [you can't close the underlying file](https://pillow.readthedocs.io/en/stable/releasenotes/7.0.0.html#image-del) (this also applies to the first example, but you could wrap it in a context manager to avoid that).

How about call `Image.Image.load()` inside `ImageOps.exif_transpose` if there's no transpose applied? return the _loaded image_ or _image.copy()_ depending on the switch flag.
The `load()` function will do the right thing, and the returned stuff which are loaded pixels is consistent as it is now.
`load()` doesn't close the file [if there are multiple frames](https://pillow.readthedocs.io/en/stable/reference/open_files.html#file-handling-in-pillow).
> `load()` doesn't close the file [if there are multiple frames](https://pillow.readthedocs.io/en/stable/reference/open_files.html#file-handling-in-pillow).

Yes, the file is not closed.
And that is the current Pillow behaves. Compare 2 code snippets below:

**Current `ImageOps.exif_transpose` in Pillow version 9.5:**
```python
from PIL import Image, ImageOps

### For single-frame image ###
# case 1: assign returned value to another variable
im = Image.open("hopper.jpg")
im2 = ImageOps.exif_transpose(im)  # the associated file is closed

# case 2: assign returned value to the same name variable
#   similar to `im=ImageOps.exif_transpose(Image.open("hopper.jpg"))`
im = Image.open("hopper.jpg")
im = ImageOps.exif_transpose(im)  # the associated file is closed


### For multi-frame image ###
# case 1:
im = Image.open("hopper.jpg")
im2 = ImageOps.exif_transpose(im)  # the associated file is still open
im.close()  # ok

# case 2:
im = Image.open("hopper.jpg")
im = ImageOps.exif_transpose(im)  # the associated file is still open
# then, resource leak because the file can not be closed later!!!
```

------

Expected function or similar thing might be:
```python
# Defined in ImageOps.py
def exif_transpose(image, copy_when_no_transpose=True):
    # check orientation in exif
    if method is not None:
        # do something
        return transposed_image
    else:
        # ★★★ call load() here, return the loaded_image or a copy ★★★
        return image.copy() if copy_when_no_transpose else loaded_image
```

**The imaginary code** will be:
```python
from PIL import Image, ImageOps

### For single-frame image ###
# case 1:
im = Image.open("hopper.jpg")
im2 = ImageOps.exif_transpose(im, copy_when_no_transpose=False)  # the associated file is closed

# case 2:
im = Image.open("hopper.jpg")
im = ImageOps.exif_transpose(im, copy_when_no_transpose=False)  # the associated file is closed


### For multi-frame image ###
# case 1:
im = Image.open("hopper.jpg")
im2 = ImageOps.exif_transpose(im, copy_when_no_transpose=False)  # the associated file is still open
im.close()  # ok

# case 2:
im = Image.open("hopper.jpg")
im = ImageOps.exif_transpose(im, copy_when_no_transpose=False)  # the associated file is still open
# then, resource leak because the file can not be closed later!!!
```


The behaviors are consistent in both situations of _real code_ and _imaginary code_.
So call `load()` inside `exif_transpose` is ok. At least it complies current Pillow API's behavior.

Correct me if i am wrong.
To be clear, I'm not suggesting that `ImageOps.exif_transpose()` should close the underlying file.

I'm saying that suggesting that users run 
```python
im = Image.open("hopper.gif")
im = ImageOps.exif_transpose(im, copy_when_no_transpose=False)
```
prevents them from closing the underlying file.

In describing Pillow's current behaviour, you've explained that case 2
```python
im = ImageOps.exif_transpose(Image.open("hopper.gif"))
```
could leave the underlying file open if the image is a multiframe image, yes. However, the user could choose to run case 1 instead.
```python
im = Image.open("hopper.gif")
im2 = ImageOps.exif_transpose(im)
im.close()
```
We don't tell users to run case 2. What you've outlined sounds to me like a good reason why you shouldn't use case 2. It goes against what we ask users to do in https://pillow.readthedocs.io/en/stable/releasenotes/7.0.0.html#image-del

Your proposed API doesn't allow for the user to close the underlying file in a simple way. After running
```python
im = Image.open("hopper.gif")
im2 = ImageOps.exif_transpose(im, copy_when_no_transpose=False)
```
if I want to ensure that the original image is closed, but not the new one, that is tricky, because I don't know if they're the same or not. I would have to
```python
im = Image.open("hopper.gif")
im2 = ImageOps.exif_transpose(im, copy_when_no_transpose=False)
if im is not im2:
  im.close()
```
That feels complicated.

Instead, my idea for an alternative was to use an `inPlace` argument.
```python
im = Image.open("hopper.gif")
ImageOps.exif_transpose(im, inPlace=True)
im.close()
```
which seems much nicer.
> if I want to ensure that the original image is closed, but not the new one, that is tricky, because I don't know if they're the same or not. I would have to
> 
> ```python
> im = Image.open("hopper.gif")
> im2 = ImageOps.exif_transpose(im, copy_when_no_transpose=False)
> if im is not im2:
>   im.close()
> ```
> 
> That feels complicated.

You're right, things are becoming complicated.

> Instead, my idea for an alternative was to use an `inPlace` argument.
> 
> ```python
> im = Image.open("hopper.gif")
> ImageOps.exif_transpose(im, inPlace=True)
> im.close()
> ```
> 
> which seems much nicer.

So, you mean to create a new API alongside the `exif_transpose`?

Resolving patch:
diff --git a/src/PIL/Image.py b/src/PIL/Image.py
--- a/src/PIL/Image.py
+++ b/src/PIL/Image.py
@@ -1433,12 +1433,12 @@ def getexif(self):
             self._exif.load(exif_info)
 
         # XMP tags
-        if 0x0112 not in self._exif:
+        if ExifTags.Base.Orientation not in self._exif:
             xmp_tags = self.info.get("XML:com.adobe.xmp")
             if xmp_tags:
                 match = re.search(r'tiff:Orientation(="|>)([0-9])', xmp_tags)
                 if match:
-                    self._exif[0x0112] = int(match[2])
+                    self._exif[ExifTags.Base.Orientation] = int(match[2])
 
         return self._exif
 
diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py
--- a/src/PIL/ImageOps.py
+++ b/src/PIL/ImageOps.py
@@ -21,7 +21,7 @@
 import operator
 import re
 
-from . import Image, ImagePalette
+from . import ExifTags, Image, ImagePalette
 
 #
 # helpers
@@ -576,19 +576,20 @@ def solarize(image, threshold=128):
     return _lut(image, lut)
 
 
-def exif_transpose(image):
+def exif_transpose(image, *, in_place=False):
     """
-    If an image has an EXIF Orientation tag, other than 1, return a new image
-    that is transposed accordingly. The new image will have the orientation
-    data removed.
-
-    Otherwise, return a copy of the image.
+    If an image has an EXIF Orientation tag, other than 1, transpose the image
+    accordingly, and remove the orientation data.
 
     :param image: The image to transpose.
-    :return: An image.
-    """
-    exif = image.getexif()
-    orientation = exif.get(0x0112)
+    :param in_place: Boolean. Keyword-only argument.
+        If ``True``, the original image is modified in-place, and ``None`` is returned.
+        If ``False`` (default), a new :py:class:`~PIL.Image.Image` object is returned
+        with the transposition applied. If there is no transposition, a copy of the
+        image will be returned.
+    """
+    image_exif = image.getexif()
+    orientation = image_exif.get(ExifTags.Base.Orientation)
     method = {
         2: Image.Transpose.FLIP_LEFT_RIGHT,
         3: Image.Transpose.ROTATE_180,
@@ -600,22 +601,28 @@ def exif_transpose(image):
     }.get(orientation)
     if method is not None:
         transposed_image = image.transpose(method)
-        transposed_exif = transposed_image.getexif()
-        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:
+        if in_place:
+            image.im = transposed_image.im
+            image.pyaccess = None
+            image._size = transposed_image._size
+        exif_image = image if in_place else transposed_image
+
+        exif = exif_image.getexif()
+        if ExifTags.Base.Orientation in exif:
+            del exif[ExifTags.Base.Orientation]
+            if "exif" in exif_image.info:
+                exif_image.info["exif"] = exif.tobytes()
+            elif "Raw profile type exif" in exif_image.info:
+                exif_image.info["Raw profile type exif"] = exif.tobytes().hex()
+            elif "XML:com.adobe.xmp" in exif_image.info:
                 for pattern in (
                     r'tiff:Orientation="([0-9])"',
                     r"<tiff:Orientation>([0-9])</tiff:Orientation>",
                 ):
-                    transposed_image.info["XML:com.adobe.xmp"] = re.sub(
-                        pattern, "", transposed_image.info["XML:com.adobe.xmp"]
+                    exif_image.info["XML:com.adobe.xmp"] = re.sub(
+                        pattern, "", exif_image.info["XML:com.adobe.xmp"]
                     )
-        return transposed_image
-    return image.copy()
+        if not in_place:
+            return transposed_image
+    elif not in_place:
+        return image.copy()
diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py
--- a/src/PIL/TiffImagePlugin.py
+++ b/src/PIL/TiffImagePlugin.py
@@ -49,7 +49,7 @@
 from fractions import Fraction
 from numbers import Number, Rational
 
-from . import Image, ImageFile, ImageOps, ImagePalette, TiffTags
+from . import ExifTags, Image, ImageFile, ImageOps, ImagePalette, TiffTags
 from ._binary import i16be as i16
 from ._binary import i32be as i32
 from ._binary import o8
@@ -1185,7 +1185,7 @@ def get_photoshop_blocks(self):
         :returns: Photoshop "Image Resource Blocks" in a dictionary.
         """
         blocks = {}
-        val = self.tag_v2.get(0x8649)
+        val = self.tag_v2.get(ExifTags.Base.ImageResources)
         if val:
             while val[:4] == b"8BIM":
                 id = i16(val[4:6])
@@ -1550,7 +1550,7 @@ def _setup(self):
             palette = [o8(b // 256) for b in self.tag_v2[COLORMAP]]
             self.palette = ImagePalette.raw("RGB;L", b"".join(palette))
 
-        self._tile_orientation = self.tag_v2.get(0x0112)
+        self._tile_orientation = self.tag_v2.get(ExifTags.Base.Orientation)
 
 
 #


Test patch:
diff --git a/Tests/images/orientation_rectangle.jpg b/Tests/images/orientation_rectangle.jpg
new file mode 100644
Binary files /dev/null and b/Tests/images/orientation_rectangle.jpg differ
diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py
--- a/Tests/test_imageops.py
+++ b/Tests/test_imageops.py
@@ -404,6 +404,18 @@ def check(orientation_im):
     assert 0x0112 not in transposed_im.getexif()
 
 
+def test_exif_transpose_in_place():
+    with Image.open("Tests/images/orientation_rectangle.jpg") as im:
+        assert im.size == (2, 1)
+        assert im.getexif()[0x0112] == 8
+        expected = im.rotate(90, expand=True)
+
+        ImageOps.exif_transpose(im, in_place=True)
+        assert im.size == (1, 2)
+        assert 0x0112 not in im.getexif()
+        assert_image_equal(im, expected)
+
+
 def test_autocontrast_cutoff():
     # Test the cutoff argument of autocontrast
     with Image.open("Tests/images/bw_gradient.png") as img: