Skip to content

Commit cced209

Browse files
fix: make the data views writable (#536)
* Make the data views writable. Previously (while working on 11.0), we changed the .bgra and .rgb attributes to be read-only memoryviews. We also removed the .raw attribute. The main reason we made the memoryviews read-only is so that we don't have to worry about cached values of .pixels and .rgb being in-sync with the underlying buffer, if users mutated the pixel data. However, I now realize that this takes away a possible valuable use case: ctypes. ctypes can only create an array (or pointer) from a buffer if it's writable; it doesn't support a read-only version. Previously, users could make a ctypes pointer using the .raw attribute. The new API doesn't give them that ability, without copying the data (or using ctypes' from_address, which is perilous). Read-only buffers also trigger a warning from PyTorch, although it's clearly-written and only is printed once. This PR, as it stands, makes the `.bgra` and `.rgb` properties return writable memoryviews. (It also restores `.raw`, this time as a deprecated alias of bgra, since there's no pressing need to remove the name entirely.) It also documents that, while these memoryviews are writable, actually modifying the data may cause undefined behavior. There's alternatives, of course. Instead of what I've got here, ChatGPT instead suggests that we leave .bgra and .rgb read-only (as they were in 10.2, since they were `bytes` objects), and to add a property like `.writable_bgra` or something. Its argument is as follows: > The problem with making .bgra writable and saying “mutating is UB > [undefined behavior]” is that Python users will absolutely see > writable buffer and think “cool, in-place image editing.” That’s > not UB in the fun C sense; it’s more like “congratulations, you have > a weird cache-invalidation footgun.” The API shape would be lying a > little. I'm making this PR for discussion and consensus on the best way to go. * Update release notes In the release notes, one change was accidentally in PR #535 instead, and one change was omitted entirely. Fix. * Apply suggestions from code review Co-authored-by: Halldor Fannar <halldorfannar@users.noreply.github.com> --------- Co-authored-by: Halldor Fannar <halldorfannar@users.noreply.github.com>
1 parent c6cd34b commit cced209

4 files changed

Lines changed: 100 additions & 34 deletions

File tree

docs/source/release-history/v11.0.0.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ writing, but should be by the time we release 11.0!)
1515

1616
#### ScreenShot attributes
1717

18-
The {py:attr}`mss.ScreenShot.raw` attribute has been removed. Use the {py:attr}`mss.ScreenShot.bgra` property instead.
18+
The {py:attr}`mss.ScreenShot.raw` attribute has been deprecated, and will soon be removed. Use the
19+
{py:attr}`mss.ScreenShot.bgra` property instead.
1920

20-
The {py:attr}`mss.ScreenShot.bgra` and {py:attr}`mss.ScreenShot.rgb` properties now will return read-only bytes-like
21+
The {py:attr}`mss.ScreenShot.bgra` and {py:attr}`mss.ScreenShot.rgb` properties now will return bytes-like
2122
{py:type}`memoryview` objects, not necessarily {py:type}`bytes` or {py:type}`bytearray` objects. For practical use
22-
cases, this should not be noticible. This change was allows faster access to screenshot data, with fewer memory copies.
23+
cases, this should not be noticible. This change allows faster access to screenshot data, with fewer memory
24+
copies.
2325

2426
### Python 3.9 EOL
2527

src/mss/screenshot.py

Lines changed: 63 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from __future__ import annotations
55

6+
import warnings
67
from typing import TYPE_CHECKING
78

89
from mss.exception import ScreenShotError
@@ -26,7 +27,6 @@ class ScreenShot:
2627
__slots__ = {"__bgra", "__pixels", "__rgb", "_raw", "pos", "size"}
2728

2829
def __init__(self, data: Buffer, monitor: Monitor, /, *, size: Size | None = None) -> None:
29-
self.__bgra: memoryview | None = None
3030
self.__pixels: Pixels | None = None
3131
self.__rgb: memoryview | None = None
3232

@@ -36,13 +36,10 @@ def __init__(self, data: Buffer, monitor: Monitor, /, *, size: Size | None = Non
3636
#: NamedTuple of the screenshot size.
3737
self.size: Size = Size(monitor["width"], monitor["height"]) if size is None else size
3838

39-
# Buffer of the raw BGRA pixels, retrieved by the
40-
# platform-specific implementations. This is kept read-write
41-
# if it was originally so, in order for _merge to work.
42-
# However, it should be made read-only before returning to the
43-
# user (via bgra), so that the cached values for __pixels and
44-
# __rgb aren't potentially inconsistent if the user changes
45-
# data.
39+
# Buffer of the raw BGRA pixels, retrieved by the platform-specific implementations. This is kept read-write if
40+
# it was originally so, in order for _merge to work, and so it can be used with ctypes. However, it should not
41+
# be modified once __pixels or __rgb have been accessed, so that the cached values for __pixels and __rgb aren't
42+
# potentially inconsistent if the user changes data.
4643
self._raw: memoryview = memoryview(data)
4744
assert self._raw.nbytes == self.size.width * self.size.height * 4, ( # noqa: S101
4845
"Data size does not match screenshot dimensions."
@@ -57,14 +54,18 @@ def __array_interface__(self) -> dict[str, Any]:
5754
"""NumPy array interface support.
5855
5956
This is used by NumPy, many SciPy projects, CuPy, PyTorch (via
60-
``torch.from_numpy``), TensorFlow (via ``tf.convert_to_tensor``),
61-
JAX (via ``jax.numpy.asarray``), Pandas, scikit-learn, Matplotlib,
57+
:py:func:`torch.from_numpy`), TensorFlow (via
58+
:py:func:`tf.convert_to_tensor`), JAX (via
59+
:py:func:`jax.numpy.asarray`), Pandas, scikit-learn, Matplotlib,
6260
some OpenCV functions, and others. This allows you to pass a
6361
:class:`ScreenShot` instance directly to these libraries without
6462
needing to convert it first.
6563
6664
This is in HWC order, with 4 channels (BGRA).
6765
66+
The array is read-write, for maximum compatibility. However,
67+
actually modifying the data may cause undefined behavior.
68+
6869
.. seealso::
6970
7071
https://numpy.org/doc/stable/reference/arrays.interface.html
@@ -74,7 +75,7 @@ def __array_interface__(self) -> dict[str, Any]:
7475
"version": 3,
7576
"shape": (self.height, self.width, 4),
7677
"typestr": "|u1",
77-
"data": self.bgra,
78+
"data": self._raw,
7879
}
7980

8081
@classmethod
@@ -91,21 +92,43 @@ def bgra(self) -> memoryview:
9192
BGRxBGRx... sequence. A specific pixel can be accessed as
9293
``bgra[(y * width + x) * 4:(y * width + x) * 4 + 4].``
9394
94-
The memoryview is read-only. PyTorch will issue a warning
95-
when given a read-only buffer, but will still work. However,
96-
actually modifying the data may cause undefined behavior.
95+
The memoryview is read-write, for compatibility with ctypes'
96+
:py:func:`from_buffer`. However, actually modifying the data
97+
may cause undefined behavior.
9798
9899
.. note::
99100
While the name is ``bgra``, the alpha channel may or may
100101
not be valid.
102+
103+
.. version-changed:: 11.0.0
104+
Prior to this version, this was a :py:class:`bytes` object.
105+
It was changed to a memoryview for improved performance.
106+
Most practical uses are unaffected by this change, as
107+
``memoryview`` supports most of the same operations as
108+
``bytes``. If needed, you can use
109+
:py:meth:`memoryview.tobytes` to get a ``bytes`` object.
101110
"""
102-
# Making a read-only copy of a memoryview is very cheap. But
103-
# we still always return the same memoryview: somebody using a
104-
# property may expect it to be identical (under the `is`
105-
# operator) every time.
106-
if self.__bgra is None:
107-
self.__bgra = self._raw.toreadonly()
108-
return self.__bgra
111+
return self._raw
112+
113+
@property
114+
def raw(self) -> memoryview:
115+
"""Deprecated alias for :py:attr:`bgra`.
116+
117+
.. version-deprecated:: 10.2.0
118+
Use :py:attr:`bgra` instead. This alias will be removed in
119+
a future version.
120+
121+
.. version-changed:: 11.0.0
122+
Prior to this version, this was a :py:class:`bytearray`.
123+
This :py:attr:`raw` alias is retained, although as a
124+
:py:class:`memoryview`, for backwards compatibility: most
125+
existing uses are not affected, as ``memoryview`` supports
126+
most of the same operations as ``bytearray``. If needed,
127+
you can use ``bytearray(raw)`` to get a ``bytearray``
128+
object.
129+
"""
130+
warnings.warn("The raw property is deprecated. Use bgra instead.", DeprecationWarning, stacklevel=2)
131+
return self._raw
109132

110133
@property
111134
def pixels(self) -> Pixels:
@@ -137,24 +160,35 @@ def rgb(self) -> memoryview:
137160
138161
The format is a memoryview object of bytes. These are in a
139162
RGBRGB... sequence. A specific pixel can be accessed as
140-
``rgb[(y * width + x) * 4:(y * width + x) * 4 + 4].``
163+
``rgb[(y * width + x) * 3:(y * width + x) * 3 + 3].``
141164
142-
The memoryview is read-only. PyTorch will issue a warning
143-
when given a read-only buffer, but will still work. However,
144-
actually modifying the data may cause undefined behavior.
165+
The memoryview is read-write, for compatibility with ctypes'
166+
:py:func:`from_buffer`. However, actually modifying the data
167+
may cause undefined behavior.
145168
146-
:: note::
169+
.. note::
147170
This is a computed property. If possible, using the
148-
:py:attr:`bgra` property directly is usually more
149-
efficient.
171+
:py:attr:`bgra` property directly is usually more efficient.
172+
173+
.. version-changed:: 11.0.0
174+
Prior to this version, this was a :py:class:`bytes` object.
175+
It was changed to a memoryview for improved performance.
176+
Most practical uses are unaffected by this change, as
177+
``memoryview`` supports most of the same operations as
178+
``bytes``. If needed, you can use
179+
:py:meth:`memoryview.tobytes` to get a ``bytes`` object.
150180
"""
151181
if self.__rgb is None:
152182
rgb = bytearray(self.height * self.width * 3)
153183
raw = self._raw
154184
rgb[::3] = raw[2::4]
155185
rgb[1::3] = raw[1::4]
156186
rgb[2::3] = raw[::4]
157-
self.__rgb = memoryview(rgb).toreadonly()
187+
# We could just return the bytearray directly. However, we would rather give ourselves the flexibility to
188+
# use other buffer types in the future. Rather than declaring our return type as just generically Buffer
189+
# (which doesn't guarantee the indexing behavior of memoryview), we always return a memoryview, which is
190+
# cheap.
191+
self.__rgb = memoryview(rgb)
158192

159193
return self.__rgb
160194

src/tests/test_bgra_to_rgb.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,30 @@
22
Source: https://github.com/BoboTiG/python-mss.
33
"""
44

5+
import ctypes
6+
57
from mss.base import ScreenShot
68

79

810
def test_good_types(raw: bytes) -> None:
911
image = ScreenShot.from_size(bytearray(raw), 1024, 768)
1012
assert isinstance(image.rgb, memoryview)
11-
assert image.rgb.readonly
1213

1314

1415
def test_contents() -> None:
1516
image = ScreenShot.from_size(b"BGRA" * 1024 * 768, 1024, 768)
1617
assert bytes(image.rgb) == b"RGB" * 1024 * 768
18+
19+
20+
def test_ctypes_pointers_from_rgb() -> None:
21+
image = ScreenShot.from_size(b"BGRA" * 4, 2, 2)
22+
assert image.rgb.readonly is False
23+
24+
rgb_array = (ctypes.c_uint8 * len(image.rgb)).from_buffer(image.rgb)
25+
void_ptr = ctypes.c_void_p(ctypes.addressof(rgb_array))
26+
uint8_ptr = ctypes.cast(void_ptr, ctypes.POINTER(ctypes.c_uint8))
27+
28+
assert void_ptr.value is not None
29+
assert uint8_ptr[0] == ord("R")
30+
assert uint8_ptr[1] == ord("G")
31+
assert uint8_ptr[2] == ord("B")

src/tests/test_get_pixels.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Source: https://github.com/BoboTiG/python-mss.
33
"""
44

5+
import ctypes
56
import itertools
67
from collections.abc import Callable
78

@@ -17,7 +18,6 @@ def test_grab_monitor(mss_impl: Callable[..., MSS]) -> None:
1718
image = sct.grab(mon)
1819
assert isinstance(image, ScreenShot)
1920
assert isinstance(image.bgra, memoryview)
20-
assert image.bgra.readonly
2121

2222

2323
def test_grab_part_of_screen(mss_impl: Callable[..., MSS]) -> None:
@@ -45,3 +45,18 @@ def test_get_pixel(raw: bytes) -> None:
4545

4646
with pytest.raises(ScreenShotError):
4747
image.pixel(image.width + 1, 12)
48+
49+
50+
def test_ctypes_pointers_from_bgra(raw: bytes) -> None:
51+
image = ScreenShot.from_size(bytearray(raw), 1024, 768)
52+
assert image.bgra.readonly is False
53+
54+
bgra_array = (ctypes.c_uint8 * len(image.bgra)).from_buffer(image.bgra)
55+
void_ptr = ctypes.c_void_p(ctypes.addressof(bgra_array))
56+
uint8_ptr = ctypes.cast(void_ptr, ctypes.POINTER(ctypes.c_uint8))
57+
58+
assert void_ptr.value is not None
59+
assert uint8_ptr[0] == raw[0]
60+
assert uint8_ptr[1] == raw[1]
61+
assert uint8_ptr[2] == raw[2]
62+
assert uint8_ptr[3] == raw[3]

0 commit comments

Comments
 (0)