Repository: python-pillow/Pillow

Issue:
Image.save() causes images created by Image.frombuffer() to stop reflecting changes in that buffer

Hi, recently messed with PIL to encounter what is probably a bug

### What did you do?

```python
import numpy as np
from PIL import Image


# Source of data for image
im_data: np.typing.NDArray = np.array([[255]], dtype=np.uint8)
assert im_data[0, 0] == 255  # OK

# Attach to buffer protocol of an array, should reflect changes in im_data
im: Image.Image = Image.frombuffer(mode="L", size=(1, 1), data=im_data)
assert im_data[0, 0] == 255  # OK
assert im.getdata()[0] == 255  # OK

im_data[:, :] = np.array([[128]], dtype=np.uint8)
assert im_data[0, 0] == 128  # OK
assert im.getdata()[0] == 128  # OK!

im.save("./test_image.bmp")
assert im_data[0, 0] == 128  # OK
assert im.getdata()[0] == 128  # OK

im_data[:, :] = np.array([[64]], dtype=np.uint8)
assert im_data[0, 0] == 64  # OK
assert im.getdata()[0] == 64  # Failed!!! It is still == 128
```

### What did you expect to happen?

Line with last assert should pass the assertion:
```
...
assert im.getdata()[0] == 64   # Should pass
```

### What actually happened?

Yes, so it seams that `Image.save()` messes with image buffer state. Moreover, this is actually explicitly stated that it does in source code:

https://github.com/python-pillow/Pillow/blob/af09976a5f57e8aebaedf332bc4025ea1b068af4/src/PIL/Image.py#L2513

```python
#     def save(...):
#         ...
           # may mutate self!
           self._ensure_mutable()

```

Once one saved the image, one can no longer work with buffer and expect image working properly.

I can *try* to work out some patches but really I am somewhat new to the pillow and not well aware of all internal connections with things. It would be nice to have some hints on what may going on - then I can try to fix this.

### What are your OS, Python and Pillow versions?

#### OS:
```shell
lsb_release -a
No LSB modules are available.
Distributor ID:	Ubuntu
Description:	Ubuntu 22.04.5 LTS
Release:	22.04
Codename:	jammy
```

#### Python: 
- `Python 3.12.9`
- Prebuilt binary from deadsnakes ppa if I remember correctly

#### Pillow: 

```shell
poetry run python3 -m PIL --report
--------------------------------------------------------------------
Pillow 11.1.0
Python 3.12.9 (main, Feb  5 2025, 08:49:00) [GCC 11.4.0]
--------------------------------------------------------------------
Python executable is [...]/.venv/bin/python3
Environment Python files loaded from [...]/.venv
System Python files loaded from /usr
--------------------------------------------------------------------
Python Pillow modules loaded from [...]/.venv/lib/python3.12/site-packages/PIL
Binary Pillow modules loaded from [...]/.venv/lib/python3.12/site-packages/PIL
--------------------------------------------------------------------
--- PIL CORE support ok, compiled for 11.1.0
*** TKINTER support not installed
--- FREETYPE2 support ok, loaded 2.13.2
--- LITTLECMS2 support ok, loaded 2.16
--- WEBP support ok, loaded 1.5.0
--- JPEG support ok, compiled for libjpeg-turbo 3.1.0
--- OPENJPEG (JPEG2000) support ok, loaded 2.5.3
--- ZLIB (PNG/ZIP) support ok, loaded 1.2.11, compiled for zlib-ng 2.2.2
--- LIBTIFF support ok, loaded 4.6.0
--- RAQM (Bidirectional Text) support ok, loaded 0.10.1, fribidi 1.0.8, harfbuzz 10.1.0
*** LIBIMAGEQUANT (Quantization method) support not installed
--- XCB (X protocol) support ok
--------------------------------------------------------------------
```

Discussion:
@radarhere: To explain slightly, you can see in `frombuffer()` that `readonly` is set to 1.

https://github.com/python-pillow/Pillow/blob/af09976a5f57e8aebaedf332bc4025ea1b068af4/src/PIL/Image.py#L3186

And when `_ensure_mutable` sees this,

https://github.com/python-pillow/Pillow/blob/af09976a5f57e8aebaedf332bc4025ea1b068af4/src/PIL/Image.py#L631-L640

`self.im = self.im.copy()` means 'clone the copy of the image that lives in C, and use the clone from now on'.

You may or may not consider this helpful - if you save a copy of the image instead, replacing `im.save("./test_image.bmp")` with `im.copy().save("./test_image.bmp")`, then the original image stays the same.

If you're looking for context as to why `save()` calls `_ensure_mutable`, see #3724.

You may also find #2973 and #2980 interesting.@igoras1993: Now I understand the context better. Ok, so can you tell my why there is still `readonly = 1` flag set in `frombuffer()` constructor? I can see that in original https://github.com/python-pillow/Pillow/pull/2980 PR this line was a subject of change:

https://github.com/python-pillow/Pillow/pull/2980/files#diff-4805c79264fea07df59058db82ed74bb2f5c5023e212ac678536a534c56e5be2R3185

This line was not touched since the original fork - this is not necessarily something bad - but maybe it can be revised, why `frombuffer()` constructor would want to have this set?

I can see that when no readonly flag is set, the `.load()` method is called - and it does some mem allocation (based on docs). Despite it being unclear to me (though I believe that there is a good reason for calling `.load()` here); I would argue that opening API for controlling this flag can be rewarding:

```python
def frombuffer(
    mode: str,
    size: tuple[int, int],
    data: bytes | SupportsArrayInterface,
    decoder_name: str = "raw",
    readonly: bool = True,
    *args: Any,
) -> Image:
    # ...
            # ...
            im.readonly = readonly
            return im

```
Then user controls it:
```python
im: Image.Image = Image.frombuffer(mode="L", size=(1, 1), data=im_data, readonly=False)
```

HOWEVER I do not know if this is a good solution at all. I can only confirm that in *my* specific case this solves the issue - I can freely call `.save()` and still have data reflected from my buffer.

I did not put any performance checks in this considerations also. 

It just *makes more sense* from the user point of view - that's all.

@radarhere: Ok, so #2980 would resolve your issue. If @wiredfool is available, he might be better placed to answer your question.@wiredfool: Apparently I've totally forgotten about that PR, since I've effectively implemented the majority of it again in the Arrow PR #8330 (at least the parts where we've got an internal read_only flag on the c image struct, but in this case it's enforced read only in the c layer where you can't change it to r/w).

I don't recall why #2980 never went anywhere, other than what I see in the PR there now, in that there are no docs or tests.  (actually, that was a couple weeks before a new job, so that was probably it)

Looking at this in the context of the Arrow PR, I think that at least one significant use of Pillow in that context will be to take a large array and save the data to an image format. It definitely defeats the point of zero copy if we can't save data without copying it into our own storage first, and then shuffling it out to the file.

The core issue with #3274 seems to be that we've opened a write connection to a file that's mmaped into our process, not that save is inherently something that requires mutability in the image data. I'd think that this should be solved at the `open()` stage, possibly with an exclusive flag or similar. 

As a somewhat side note -- once an arrow array is exported from a process, according to the arrow implementation guidelines, both ends are supposed to consider it read-only due to the likelihood of inconsistent data when there are writers and readers in separate threads. I assume similar issues could occur with the frombuffer call, though given the GIL and the python focus of the array interface, it's less likely than with arrow.


@radarhere: Would https://github.com/radarhere/Pillow/commit/600411a1e60274d154d015b446e521ad225f8498 be sufficient to resolve this? I've tested it, and it causes your original code to work.@radarhere: I've created #8852 to resolve this.@radarhere: Pillow 11.2.1 has now been released with the fix for this.

Solution patch:
diff --git a/docs/reference/ImageGrab.rst b/docs/reference/ImageGrab.rst
index db2987eb0..54d66db13 100644
--- a/docs/reference/ImageGrab.rst
+++ b/docs/reference/ImageGrab.rst
@@ -9,7 +9,7 @@ or the clipboard to a PIL image memory.
 
 .. versionadded:: 1.1.3
 
-.. py:function:: grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=None)
+.. py:function:: grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=None, window=None)
 
     Take a snapshot of the screen. The pixels inside the bounding box are returned as
     an "RGBA" on macOS, or an "RGB" image otherwise. If the bounding box is omitted,
@@ -39,6 +39,11 @@ or the clipboard to a PIL image memory.
         You can check X11 support using :py:func:`PIL.features.check_feature` with ``feature="xcb"``.
 
         .. versionadded:: 7.1.0
+
+    :param window:
+        HWND, to capture a single window. Windows only.
+
+        .. versionadded:: 11.2.0
     :return: An image
 
 .. py:function:: grabclipboard()
diff --git a/docs/releasenotes/11.2.0.rst b/docs/releasenotes/11.2.0.rst
index dbaa8a4a4..de3db3c84 100644
--- a/docs/releasenotes/11.2.0.rst
+++ b/docs/releasenotes/11.2.0.rst
@@ -51,6 +51,15 @@ aligned using ``"justify"`` in :py:mod:`~PIL.ImageDraw`::
     draw.multiline_text((0, 0), "Multiline\ntext 1", align="justify")
     draw.multiline_textbbox((0, 0), "Multiline\ntext 2", align="justify")
 
+Specify window in ImageGrab on Windows
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+When using :py:meth:`~PIL.ImageGrab.grab`, a specific window can be selected using the
+HWND::
+
+    from PIL import ImageGrab
+    ImageGrab.grab(window=hwnd)
+
 Check for MozJPEG
 ^^^^^^^^^^^^^^^^^
 
@@ -72,6 +81,28 @@ DXT5, BC2, BC3 and BC5 are supported::
 Other Changes
 =============
 
+Arrow support
+^^^^^^^^^^^^^
+
+`Arrow <https://arrow.apache.org/>`__ is an in-memory data exchange format that is the
+spiritual successor to the NumPy array interface. It provides for zero-copy access to
+columnar data, which in our case is ``Image`` data.
+
+To create an image with zero-copy shared memory from an object exporting the
+arrow_c_array interface protocol::
+
+    from PIL import Image
+    import pyarrow as pa
+    arr = pa.array([0]*(5*5*4), type=pa.uint8())
+    im = Image.fromarrow(arr, 'RGBA', (5, 5))
+
+Pillow images can also be converted to Arrow objects::
+
+    from PIL import Image
+    import pyarrow as pa
+    im = Image.open('hopper.jpg')
+    arr = pa.array(im)
+
 Reading and writing AVIF images
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
diff --git a/src/PIL/Image.py b/src/PIL/Image.py
index 233df592c..c62d7a8a3 100644
--- a/src/PIL/Image.py
+++ b/src/PIL/Image.py
@@ -2540,8 +2540,13 @@ class Image:
                 msg = f"unknown file extension: {ext}"
                 raise ValueError(msg) from e
 
+        from . import ImageFile
+
         # may mutate self!
-        self._ensure_mutable()
+        if isinstance(self, ImageFile.ImageFile) and filename == self.filename:
+            self._ensure_mutable()
+        else:
+            self.load()
 
         save_all = params.pop("save_all", None)
         self.encoderinfo = {**getattr(self, "encoderinfo", {}), **params}
diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py
index fe27bfaeb..42abdf8c7 100644
--- a/src/PIL/ImageGrab.py
+++ b/src/PIL/ImageGrab.py
@@ -25,12 +25,17 @@ import tempfile
 
 from . import Image
 
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+    from . import ImageWin
+
 
 def grab(
     bbox: tuple[int, int, int, int] | None = None,
     include_layered_windows: bool = False,
     all_screens: bool = False,
     xdisplay: str | None = None,
+    window: int | ImageWin.HWND | None = None,
 ) -> Image.Image:
     im: Image.Image
     if xdisplay is None:
@@ -51,8 +56,12 @@ def grab(
                 return im_resized
             return im
         elif sys.platform == "win32":
+            if window is not None:
+                all_screens = -1
             offset, size, data = Image.core.grabscreen_win32(
-                include_layered_windows, all_screens
+                include_layered_windows,
+                all_screens,
+                int(window) if window is not None else 0,
             )
             im = Image.frombytes(
                 "RGB",
diff --git a/src/display.c b/src/display.c
index ac2134a57..11742a895 100644
--- a/src/display.c
+++ b/src/display.c
@@ -286,29 +286,42 @@ PyImaging_DisplayModeWin32(PyObject *self, PyObject *args) {
 /* -------------------------------------------------------------------- */
 /* Windows screen grabber */
 
+typedef HANDLE(__stdcall *Func_GetWindowDpiAwarenessContext)(HANDLE);
 typedef HANDLE(__stdcall *Func_SetThreadDpiAwarenessContext)(HANDLE);
 
 PyObject *
 PyImaging_GrabScreenWin32(PyObject *self, PyObject *args) {
-    int x = 0, y = 0, width, height;
-    int includeLayeredWindows = 0, all_screens = 0;
+    int x = 0, y = 0, width = -1, height;
+    int includeLayeredWindows = 0, screens = 0;
     HBITMAP bitmap;
     BITMAPCOREHEADER core;
     HDC screen, screen_copy;
+    HWND wnd;
     DWORD rop;
     PyObject *buffer;
-    HANDLE dpiAwareness;
+    HANDLE dpiAwareness = NULL;
     HMODULE user32;
+    Func_GetWindowDpiAwarenessContext GetWindowDpiAwarenessContext_function;
     Func_SetThreadDpiAwarenessContext SetThreadDpiAwarenessContext_function;
 
-    if (!PyArg_ParseTuple(args, "|ii", &includeLayeredWindows, &all_screens)) {
+    if (!PyArg_ParseTuple(
+            args, "|ii" F_HANDLE, &includeLayeredWindows, &screens, &wnd
+        )) {
         return NULL;
     }
 
     /* step 1: create a memory DC large enough to hold the
        entire screen */
 
-    screen = CreateDC("DISPLAY", NULL, NULL, NULL);
+    if (screens == -1) {
+        screen = GetDC(wnd);
+        if (screen == NULL) {
+            PyErr_SetString(PyExc_OSError, "unable to get device context for handle");
+            return NULL;
+        }
+    } else {
+        screen = CreateDC("DISPLAY", NULL, NULL, NULL);
+    }
     screen_copy = CreateCompatibleDC(screen);
 
     // added in Windows 10 (1607)
@@ -317,15 +330,28 @@ PyImaging_GrabScreenWin32(PyObject *self, PyObject *args) {
     SetThreadDpiAwarenessContext_function = (Func_SetThreadDpiAwarenessContext
     )GetProcAddress(user32, "SetThreadDpiAwarenessContext");
     if (SetThreadDpiAwarenessContext_function != NULL) {
+        GetWindowDpiAwarenessContext_function = (Func_GetWindowDpiAwarenessContext
+        )GetProcAddress(user32, "GetWindowDpiAwarenessContext");
+        if (screens == -1 && GetWindowDpiAwarenessContext_function != NULL) {
+            dpiAwareness = GetWindowDpiAwarenessContext_function(wnd);
+        }
         // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE = ((DPI_CONTEXT_HANDLE)-3)
-        dpiAwareness = SetThreadDpiAwarenessContext_function((HANDLE)-3);
+        dpiAwareness = SetThreadDpiAwarenessContext_function(
+            dpiAwareness == NULL ? (HANDLE)-3 : dpiAwareness
+        );
     }
 
-    if (all_screens) {
+    if (screens == 1) {
         x = GetSystemMetrics(SM_XVIRTUALSCREEN);
         y = GetSystemMetrics(SM_YVIRTUALSCREEN);
         width = GetSystemMetrics(SM_CXVIRTUALSCREEN);
         height = GetSystemMetrics(SM_CYVIRTUALSCREEN);
+    } else if (screens == -1) {
+        RECT rect;
+        if (GetClientRect(wnd, &rect)) {
+            width = rect.right;
+            height = rect.bottom;
+        }
     } else {
         width = GetDeviceCaps(screen, HORZRES);
         height = GetDeviceCaps(screen, VERTRES);
@@ -337,6 +363,10 @@ PyImaging_GrabScreenWin32(PyObject *self, PyObject *args) {
 
     FreeLibrary(user32);
 
+    if (width == -1) {
+        goto error;
+    }
+
     bitmap = CreateCompatibleBitmap(screen, width, height);
     if (!bitmap) {
         goto error;
@@ -382,7 +412,11 @@ PyImaging_GrabScreenWin32(PyObject *self, PyObject *args) {
 
     DeleteObject(bitmap);
     DeleteDC(screen_copy);
-    DeleteDC(screen);
+    if (screens == -1) {
+        ReleaseDC(wnd, screen);
+    } else {
+        DeleteDC(screen);
+    }
 
     return Py_BuildValue("(ii)(ii)N", x, y, width, height, buffer);
 
@@ -390,7 +424,11 @@ error:
     PyErr_SetString(PyExc_OSError, "screen grab failed");
 
     DeleteDC(screen_copy);
-    DeleteDC(screen);
+    if (screens == -1) {
+        ReleaseDC(wnd, screen);
+    } else {
+        DeleteDC(screen);
+    }
 
     return NULL;
 }


Test patch:
diff --git a/Tests/test_image.py b/Tests/test_image.py
index c2e850c36..7e6118d52 100644
--- a/Tests/test_image.py
+++ b/Tests/test_image.py
@@ -258,6 +258,15 @@ class TestImage:
             assert im.readonly
             im.save(temp_file)
 
+    def test_save_without_changing_readonly(self, tmp_path: Path) -> None:
+        temp_file = tmp_path / "temp.bmp"
+
+        with Image.open("Tests/images/rgb32bf-rgba.bmp") as im:
+            assert im.readonly
+
+            im.save(temp_file)
+            assert im.readonly
+
     def test_dump(self, tmp_path: Path) -> None:
         im = Image.new("L", (10, 10))
         im._dump(str(tmp_path / "temp_L.ppm"))
diff --git a/Tests/test_imagegrab.py b/Tests/test_imagegrab.py
index 5cd510751..35aaa7ff0 100644
--- a/Tests/test_imagegrab.py
+++ b/Tests/test_imagegrab.py
@@ -57,6 +57,13 @@ class TestImageGrab:
             ImageGrab.grab(xdisplay="error.test:0.0")
         assert str(e.value).startswith("X connection failed")
 
+    @pytest.mark.skipif(sys.platform != "win32", reason="Windows only")
+    def test_grab_invalid_handle(self) -> None:
+        with pytest.raises(OSError, match="unable to get device context for handle"):
+            ImageGrab.grab(window=-1)
+        with pytest.raises(OSError, match="screen grab failed"):
+            ImageGrab.grab(window=0)
+
     def test_grabclipboard(self) -> None:
         if sys.platform == "darwin":
             subprocess.call(["screencapture", "-cx"])