Repository: python-pillow/Pillow

Issue:
Change `Image.tobytes` return type to ByteArray or add a `Image.tobytearray` method
Currently the method `Image.tobytes` returns a byte string. There is nothing inherently bad about this, except that the returned bytes are _read only_ (ofc) since bytes are immutable.

The consequence of this is that `Image.__array__` does an extra copy when calling 

https://github.com/python-pillow/Pillow/blob/a3d1e2f85db890c6a1623e3902711863f55cc54e/src/PIL/Image.py#L678

that (I think) can be avoided. The data is copied from the immutable bytes string into a new mutable memory block around which the array is constructed.

This copy can be avoided in a few ways:

1. Chance the code in `Image.__array__` to (roughly) `np.frombuffer(self.tobytes(), dtype=dtype).reshape(shape)`. The downside is that the result is read-only (`result.flags['WRITEABLE'] == False`) which may be undesirable.
2. Change the code in `Image.tobytes` to return a `bytearray`. This may require work, depending on what `encoder` returns (didn't dive that far into the code), and it may be undesirable because type(Image.tobytes()) changes.
3. Introduce a new `Image.tobytearray` method that essentially mirrors `Image.tobytes`, except that it creates a mutable buffer. This resolves the problems that come with changing `Image.tobytes` return type, but may still need more work.

Would this be something interesting and/or useful to do?


Discussion:
Just a note - one day, the deprecation in https://github.com/numpy/numpy/pull/19001 will be removed, probably in NumPy 1.23, paving the way for us to revert #5379 and move from `__array_interface__` back to `__array__`- making whatever changes we do here redundant. But that is in the future.

I agree that 2 is undesirable, because we value backwards compatibility.

So 3 is most likely the solution - but I don't think it necessarily needs to be a public method, if there's no obvious use case apart from this.
@radarhere Yes, other use-cases could go through numpy to get the writable buffer: `np.array(pil_image).data`. Then again numpy isn't a dependency of pillow, iirc, so it may not be available. I don't think this would be a common situation though, and if it does arise then `Image._tobytearray` can always be made public later.
(NumPy minor releases (1.x.0) go out twice a year, so 1.23.0 is due out mid-2022.)
https://github.com/numpy/numpy/pull/20835 has been merged into NumPy, so that will be part of NumPy 1.23.

Resolving patch:
diff --git a/src/PIL/Image.py b/src/PIL/Image.py
--- a/src/PIL/Image.py
+++ b/src/PIL/Image.py
@@ -671,14 +671,9 @@ def _repr_png_(self):
             raise ValueError("Could not save to PNG for display") from e
         return b.getvalue()
 
-    class _ArrayData:
-        def __init__(self, new):
-            self.__array_interface__ = new
-
-    def __array__(self, dtype=None):
+    @property
+    def __array_interface__(self):
         # numpy array interface support
-        import numpy as np
-
         new = {}
         shape, typestr = _conv_type_shape(self)
         new["shape"] = shape
@@ -690,8 +685,7 @@ def __array__(self, dtype=None):
             new["data"] = self.tobytes("raw", "L")
         else:
             new["data"] = self.tobytes()
-
-        return np.array(self._ArrayData(new), dtype)
+        return new
 
     def __getstate__(self):
         return [self.info, self.mode, self.size, self.getpalette(), self.tobytes()]


Test patch:
diff --git a/Tests/test_image_array.py b/Tests/test_image_array.py
--- a/Tests/test_image_array.py
+++ b/Tests/test_image_array.py
@@ -1,4 +1,5 @@
 import pytest
+from packaging.version import parse as parse_version
 
 from PIL import Image
 
@@ -34,9 +35,10 @@ def test_with_dtype(dtype):
     test_with_dtype(numpy.float64)
     test_with_dtype(numpy.uint8)
 
-    with Image.open("Tests/images/truncated_jpeg.jpg") as im_truncated:
-        with pytest.raises(OSError):
-            numpy.array(im_truncated)
+    if parse_version(numpy.__version__) >= parse_version("1.23"):
+        with Image.open("Tests/images/truncated_jpeg.jpg") as im_truncated:
+            with pytest.raises(OSError):
+                numpy.array(im_truncated)
 
 
 def test_fromarray():