From d8ff2453ecdb535d4512e041ae81b3fd466c8d38 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Thu, 20 Aug 2026 13:24:09 +0300 Subject: [PATCH] Un-deprecate `getdata()`; have it return an ImageLinearAccess object --- Tests/test_image_getdata.py | 110 ++++++++++++++++++++--- docs/deprecations.rst | 9 -- docs/releasenotes/13.0.0.rst | 11 +++ src/PIL/Image.py | 16 ++-- src/PIL/_imaging.pyi | 6 ++ src/_imaging.c | 165 +++++++++++++++++++++++++++++++++++ 6 files changed, 291 insertions(+), 26 deletions(-) diff --git a/Tests/test_image_getdata.py b/Tests/test_image_getdata.py index 94d6cbaa2e7..9397724acef 100644 --- a/Tests/test_image_getdata.py +++ b/Tests/test_image_getdata.py @@ -1,5 +1,7 @@ from __future__ import annotations +import operator + import pytest from PIL import Image @@ -7,17 +9,27 @@ from .helper import hopper -def test_sanity() -> None: - data = hopper().get_flattened_data() +@pytest.mark.parametrize("method", ["getdata", "get_flattened_data"]) +def test_sanity(method: str) -> None: + im = hopper() + data = getattr(im, method)() assert len(data) == 128 * 128 assert data[0] == (20, 20, 70) + assert data[-1] == data[128 * 128 - 1] + # And the single-band case + band = getattr(im, method)(0) + assert len(band) == 128 * 128 + assert band[0] == 20 + assert band[-1] == band[128 * 128 - 1] -def test_mode() -> None: + +@pytest.mark.parametrize("method", ["getdata", "get_flattened_data"]) +def test_mode(method: str) -> None: def getdata(mode: str) -> tuple[float | tuple[int, ...] | None, int, int]: im = hopper(mode).resize((32, 30), Image.Resampling.NEAREST) - data = im.get_flattened_data() + data = getattr(im, method)() return data[0], len(data), len(list(data)) assert getdata("1") == (0, 960, 960) @@ -30,11 +42,87 @@ def getdata(mode: str) -> tuple[float | tuple[int, ...] | None, int, int]: assert getdata("YCbCr") == ((16, 147, 123), 960, 960) -def test_deprecation() -> None: - im = hopper() - with pytest.warns(DeprecationWarning, match="getdata"): - data = im.getdata() +def test_index_out_of_range() -> None: + hopper_data = hopper().getdata() + for index in (128 * 128, -128 * 128 - 1, 1 << 40): + with pytest.raises(IndexError): + _ = hopper_data[index] - assert len(data) == 128 * 128 - assert data[0] == (20, 20, 70) - assert list(data)[0] == (20, 20, 70) + +def test_iteration_matches_indexing() -> None: + hopper_data = hopper().getdata() + as_list = list(hopper_data) + assert len(as_list) == len(hopper_data) + assert as_list == [hopper_data[i] for i in range(len(hopper_data))] + + +def test_iteration_is_repeatable() -> None: + hopper_data = hopper().getdata() + assert list(hopper_data) == list(hopper_data) + + +def test_exhausted_iterator_stays_exhausted() -> None: + hopper_data = hopper().getdata() + it = iter(hopper_data) + assert len(list(it)) == len(hopper_data) + assert list(it) == [] + with pytest.raises(StopIteration): + next(it) + + +def test_length_hint() -> None: + hopper_data = hopper().getdata() + it = iter(hopper_data) + assert operator.length_hint(it) == len(hopper_data) + next(it) + assert operator.length_hint(it) == len(hopper_data) - 1 + list(it) + assert operator.length_hint(it) == 0 + + +def test_matches_get_flattened_data() -> None: + assert tuple(hopper().getdata()) == hopper().get_flattened_data() + + +def test_is_read_only() -> None: + hopper_data = hopper().getdata() + with pytest.raises(TypeError): + hopper_data[0] = (0, 0, 0) # type: ignore[index] + + +def test_getdata_does_not_expose_the_image_core() -> None: + hopper_data = hopper().getdata() + # "Weird" core bits are not exposed: + for name in ["putpixel", "paste", "ptr", "putdata"]: + assert not hasattr(hopper_data, name) + + +@pytest.mark.parametrize("size", [(0, 0), (0, 5), (5, 0)]) +def test_empty_image(size: tuple[int, int]) -> None: + data = Image.new("RGB", size).getdata() + assert len(data) == 0 + assert list(data) == [] + with pytest.raises(IndexError): + _ = data[0] + + +def test_outlives_the_image() -> None: + im = Image.new("RGB", (2, 2), (1, 2, 3)) + data = im.getdata() + del im + assert list(data) == [(1, 2, 3)] * 4 + + +def test_is_a_live_view() -> None: + im = Image.new("RGB", (2, 2), (0, 0, 0)) + data = im.getdata() + im.putpixel((0, 0), (9, 9, 9)) + assert data[0] == (9, 9, 9) + + +def test_accepted_by_putdata() -> None: + im = hopper() + hopper_data = im.getdata() + out = Image.new("RGB", im.size) + out.putdata(hopper_data) + assert out.tobytes() == im.tobytes() diff --git a/docs/deprecations.rst b/docs/deprecations.rst index 70745104483..5a9a9fe57ff 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -20,15 +20,6 @@ ExifTags.IFD.Makernote ``ExifTags.IFD.Makernote`` has been deprecated. Instead, use ``ExifTags.IFD.MakerNote``. -Image getdata() -~~~~~~~~~~~~~~~ - -.. deprecated:: 12.1.0 - -:py:meth:`~PIL.Image.Image.getdata` has been deprecated. -:py:meth:`~PIL.Image.Image.get_flattened_data` can be used instead. This new method is -identical, except that it returns a tuple of pixel values, instead of an internal -Pillow data type. Removed features ---------------- diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 45667d9ec07..6cc583922bb 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -77,6 +77,17 @@ TODO API changes =========== +Image getdata() +^^^^^^^^^^^^^^^ + +:py:meth:`~PIL.Image.Image.getdata` had been marked deprecated since 12.1.0, +to be removed in Pillow 14, as it returned a poorly specified internal Pillow data type. + +:py:meth:`~PIL.Image.Image.getdata` now returns an ``ImageLinearAccess`` object, +a read-only, lazy view onto the image, supporting the same operations that had been +documented for the old return value (``len()``, indexing and iteration). + + TODO ^^^^ diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 3493df1e467..c346b8049a6 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -54,7 +54,6 @@ _plugins, ) from ._binary import i32le, o32be, o32le -from ._deprecate import deprecate from ._util import DeferredError, is_path ElementTree: ModuleType | None @@ -1513,7 +1512,7 @@ def getcolors( return out return self.im.getcolors(maxcolors) - def getdata(self, band: int | None = None) -> core.ImagingCore: + def getdata(self, band: int | None = None) -> core.ImageLinearAccess: """ Returns the contents of this image as a sequence object containing pixel values. The sequence object is flattened, so @@ -1530,12 +1529,11 @@ def getdata(self, band: int | None = None) -> core.ImagingCore: value (e.g. 0 to get the "R" band from an "RGB" image). :returns: A sequence-like object. """ - deprecate("Image.Image.getdata", 14, "get_flattened_data") self.load() if band is not None: - return self.im.getband(band) - return self.im # could be abused + return self.im.getband(band).linear_access() + return self.im.linear_access() def get_flattened_data( self, band: int | None = None @@ -2089,7 +2087,13 @@ def putalpha(self, alpha: Image | int) -> None: def putdata( self, - data: Sequence[float] | Sequence[Sequence[int]] | core.ImagingCore | NumpyArray, + data: ( + Sequence[float] + | Sequence[Sequence[int]] + | core.ImagingCore + | core.ImageLinearAccess + | NumpyArray + ), scale: float = 1.0, offset: float = 0.0, ) -> None: diff --git a/src/PIL/_imaging.pyi b/src/PIL/_imaging.pyi index 81028a5960a..f69274cb59d 100644 --- a/src/PIL/_imaging.pyi +++ b/src/PIL/_imaging.pyi @@ -1,3 +1,4 @@ +from collections.abc import Iterator from typing import Any class ImagingCore: @@ -16,6 +17,11 @@ class PixelAccess: self, xy: tuple[int, int], color: float | tuple[int, ...] ) -> None: ... +class ImageLinearAccess: + def __len__(self) -> int: ... + def __getitem__(self, index: int) -> float | tuple[int, ...]: ... + def __iter__(self) -> Iterator[float | tuple[int, ...]]: ... + class ImagingDecoder: def __getattr__(self, name: str) -> Any: ... diff --git a/src/_imaging.c b/src/_imaging.c index 9bdb6328782..b00e1dbfa14 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -151,6 +151,21 @@ typedef struct { static PyTypeObject PixelAccess_Type; +typedef struct { + PyObject_HEAD ImagingObject *image; +} ImageLinearAccessObject; + +static PyTypeObject ImageLinearAccess_Type; + +typedef struct { + PyObject_HEAD ImagingObject *image; // NULL once exhausted + Py_ssize_t index; + Py_ssize_t count; + int x, y; +} ImageLinearAccessIterObject; + +static PyTypeObject ImageLinearAccessIter_Type; + PyObject * PyImagingNew(Imaging imOut) { ImagingObject *imagep; @@ -3599,6 +3614,148 @@ pixel_access_setitem(PixelAccessObject *self, PyObject *xy, PyObject *color) { return 0; } +/* -------------------------------------------------------------------- */ +/* LINEAR ACCESS */ +/* -------------------------------------------------------------------- */ + +static PyObject * +linear_access_new(ImagingObject *imagep, PyObject *args) { + ImageLinearAccessObject *self = + PyObject_New(ImageLinearAccessObject, &ImageLinearAccess_Type); + if (self == NULL) { + return NULL; + } + + /* keep a reference to the image object */ + Py_INCREF(imagep); + self->image = imagep; + + return (PyObject *)self; +} + +static void +linear_access_dealloc(ImageLinearAccessObject *self) { + Py_XDECREF(self->image); + PyObject_Del(self); +} + +static Py_ssize_t +linear_access_length(ImageLinearAccessObject *self) { + Imaging im = self->image->image; + return (Py_ssize_t)im->xsize * im->ysize; +} + +static PyObject * +image_item(ImagingObject *self, Py_ssize_t i); + +static PyObject * +linear_access_item(ImageLinearAccessObject *self, Py_ssize_t i) { + Imaging im = self->image->image; + // Python will have dealt with wrapping negative indices for us. + if (i < 0 || i >= (Py_ssize_t)im->xsize * im->ysize) { + PyErr_SetString(PyExc_IndexError, outside_image); + return NULL; + } + return image_item(self->image, i); +} + +static PyObject * +linear_access_iter(ImageLinearAccessObject *self) { + Imaging im = self->image->image; + + ImageLinearAccessIterObject *it = + PyObject_New(ImageLinearAccessIterObject, &ImageLinearAccessIter_Type); + if (it == NULL) { + return NULL; + } + + Py_INCREF(self->image); + it->image = self->image; + + // Keep track of both x and y (because getpixel needs them anyhow) + // and index/count (so we don't need to recompute it->count) + // on every iteration. + it->index = 0; + it->count = (Py_ssize_t)im->xsize * im->ysize; + it->x = 0; + it->y = 0; + + return (PyObject *)it; +} + +static PySequenceMethods linear_access_as_sequence = { + (lenfunc)linear_access_length, /*sq_length*/ + (binaryfunc)NULL, /*sq_concat*/ + (ssizeargfunc)NULL, /*sq_repeat*/ + (ssizeargfunc)linear_access_item, /*sq_item*/ + (ssizessizeargfunc)NULL, /*sq_slice*/ + (ssizeobjargproc)NULL, /*sq_ass_item*/ + (ssizessizeobjargproc)NULL, /*sq_ass_slice*/ +}; + +static PyTypeObject ImageLinearAccess_Type = { + PyVarObject_HEAD_INIT(NULL, 0).tp_name = "ImageLinearAccess", + .tp_basicsize = sizeof(ImageLinearAccessObject), + .tp_dealloc = (destructor)linear_access_dealloc, + .tp_as_sequence = &linear_access_as_sequence, + .tp_iter = (getiterfunc)linear_access_iter, +}; + +static PyObject * +linear_access_iter_next(ImageLinearAccessIterObject *self) { + if (self->image == NULL) { + return NULL; + } + + if (self->index >= self->count) { +#ifndef Py_GIL_DISABLED + // On free-threading builds, calls to this function may be concurrent + // and one invocation could have `Py_CLEAR()`ed `self->image` and the + // other might try and deref it below, leading to a crash. + // On regular builds, we clear here to get rid of one reference + // to the image a little more eagerly. + Py_CLEAR(self->image); +#endif + return NULL; + } + + Imaging im = self->image->image; + PyObject *value = getpixel(im, self->image->access, self->x, self->y); + + self->index++; + if (++self->x >= im->xsize) { + self->x = 0; + self->y++; + } + + return value; +} + +static void +linear_access_iter_dealloc(ImageLinearAccessIterObject *self) { + Py_XDECREF(self->image); + PyObject_Del(self); +} + +static PyObject * +linear_access_iter_length_hint(ImageLinearAccessIterObject *self, PyObject *args) { + return PyLong_FromSsize_t(self->image == NULL ? 0 : self->count - self->index); +} + +static struct PyMethodDef linear_access_iter_methods[] = { + {"__length_hint__", (PyCFunction)linear_access_iter_length_hint, METH_NOARGS}, + {NULL, NULL} /* sentinel */ +}; + +static PyTypeObject ImageLinearAccessIter_Type = { + PyVarObject_HEAD_INIT(NULL, 0).tp_name = "ImageLinearAccessIterator", + .tp_basicsize = sizeof(ImageLinearAccessIterObject), + .tp_dealloc = (destructor)linear_access_iter_dealloc, + .tp_iter = (getiterfunc)PyObject_SelfIter, + .tp_iternext = (iternextfunc)linear_access_iter_next, + .tp_methods = linear_access_iter_methods, +}; + /* -------------------------------------------------------------------- */ /* EFFECTS (experimental) */ /* -------------------------------------------------------------------- */ @@ -3701,6 +3858,7 @@ static struct PyMethodDef methods[] = { {"putpixel", (PyCFunction)_putpixel, METH_VARARGS}, {"pixel_access", (PyCFunction)pixel_access_new, METH_VARARGS}, + {"linear_access", (PyCFunction)linear_access_new, METH_NOARGS}, /* Standard processing methods (Image) */ {"color_lut_3d", (PyCFunction)_color_lut_3d, METH_VARARGS}, @@ -3837,6 +3995,7 @@ image_length(ImagingObject *self) { return (Py_ssize_t)im->xsize * im->ysize; } +// Reused by ImageLinearAccess static PyObject * image_item(ImagingObject *self, Py_ssize_t i) { int x, y; @@ -4330,6 +4489,12 @@ setup_module(PyObject *m) { if (PyType_Ready(&PixelAccess_Type) < 0) { return -1; } + if (PyType_Ready(&ImageLinearAccess_Type) < 0) { + return -1; + } + if (PyType_Ready(&ImageLinearAccessIter_Type) < 0) { + return -1; + } #ifdef HAVE_LIBJPEG {