From 22ac079f2f66585d04dc8c531ab150ea27b10168 Mon Sep 17 00:00:00 2001 From: Yay295 Date: Tue, 11 Jul 2023 20:33:19 +1000 Subject: [PATCH 1/6] Add image equality tests --- Tests/test_image_equality.py | 66 ++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 Tests/test_image_equality.py diff --git a/Tests/test_image_equality.py b/Tests/test_image_equality.py new file mode 100644 index 00000000000..3b5dcaab494 --- /dev/null +++ b/Tests/test_image_equality.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import itertools + +import pytest + +from PIL import Image + + +@pytest.mark.parametrize("mode", Image.MODES) +def test_equal(mode): + num_img_bytes = len(Image.new(mode, (2, 2)).tobytes()) + data = bytes(range(ord("A"), ord("A") + num_img_bytes)) + img_a = Image.frombytes(mode, (2, 2), data) + img_b = Image.frombytes(mode, (2, 2), data) + assert img_a.tobytes() == img_b.tobytes() + assert img_a.im == img_b.im + + +# With mode "1" different bytes can map to the same value, +# so we have to be more specific with the values we use. +@pytest.mark.parametrize( + "bytes_a, bytes_b", + itertools.permutations( + (bytes(x) for x in itertools.product(b"\x00\xff", repeat=4)), 2 + ), +) +def test_not_equal_mode_1(bytes_a, bytes_b): + # Use rawmode "1;8" so that each full byte is interpreted as a value + # instead of the bits in the bytes being interpreted as values. + img_a = Image.frombytes("1", (2, 2), bytes_a, "raw", "1;8") + img_b = Image.frombytes("1", (2, 2), bytes_b, "raw", "1;8") + assert img_a.tobytes() != img_b.tobytes() + assert img_a.im != img_b.im + + +@pytest.mark.parametrize("mode", [mode for mode in Image.MODES if mode != "1"]) +def test_not_equal(mode): + num_img_bytes = len(Image.new(mode, (2, 2)).tobytes()) + data_a = bytes(range(ord("A"), ord("A") + num_img_bytes)) + data_b = bytes(range(ord("Z"), ord("Z") - num_img_bytes, -1)) + img_a = Image.frombytes(mode, (2, 2), data_a) + img_b = Image.frombytes(mode, (2, 2), data_b) + assert img_a.tobytes() != img_b.tobytes() + assert img_a.im != img_b.im + + +@pytest.mark.parametrize("mode", ("RGB", "YCbCr", "HSV", "LAB")) +def test_equal_three_channels_four_bytes(mode): + # The "A" and "B" values in LAB images are signed values from -128 to 127, + # but we store them as unsigned values from 0 to 255, so we need to use + # slightly different input bytes for LAB to get the same output. + img_a = Image.new(mode, (1, 1), 0x00B3B231 if mode == "LAB" else 0x00333231) + img_b = Image.new(mode, (1, 1), 0xFFB3B231 if mode == "LAB" else 0xFF333231) + assert img_a.tobytes() == b"123" + assert img_b.tobytes() == b"123" + assert img_a.im == img_b.im + + +@pytest.mark.parametrize("mode", ("LA", "La", "PA")) +def test_equal_two_channels_four_bytes(mode): + img_a = Image.new(mode, (1, 1), 0x32000031) + img_b = Image.new(mode, (1, 1), 0x32FFFF31) + assert img_a.tobytes() == b"12" + assert img_b.tobytes() == b"12" + assert img_a.im == img_b.im From 04ce23b22fc1dde11e7fd2727578944a245c380d Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Sun, 20 Sep 2026 22:18:07 +0300 Subject: [PATCH 2/6] Adjust equality tests * de-parametrize test_not_equal_mode_1 * add type annotations * test the Image's behavior, not the Core's --- Tests/test_image_equality.py | 50 +++++++++++++++++------------------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/Tests/test_image_equality.py b/Tests/test_image_equality.py index 3b5dcaab494..6e5a7c8c212 100644 --- a/Tests/test_image_equality.py +++ b/Tests/test_image_equality.py @@ -8,59 +8,55 @@ @pytest.mark.parametrize("mode", Image.MODES) -def test_equal(mode): +def test_equal(mode: str) -> None: num_img_bytes = len(Image.new(mode, (2, 2)).tobytes()) data = bytes(range(ord("A"), ord("A") + num_img_bytes)) img_a = Image.frombytes(mode, (2, 2), data) img_b = Image.frombytes(mode, (2, 2), data) assert img_a.tobytes() == img_b.tobytes() - assert img_a.im == img_b.im + assert img_a == img_b -# With mode "1" different bytes can map to the same value, -# so we have to be more specific with the values we use. -@pytest.mark.parametrize( - "bytes_a, bytes_b", - itertools.permutations( +def test_not_equal_mode_1() -> None: + # With mode "1" different bytes can map to the same value, + # so we have to be more specific with the values we use. + for bytes_a, bytes_b in itertools.permutations( (bytes(x) for x in itertools.product(b"\x00\xff", repeat=4)), 2 - ), -) -def test_not_equal_mode_1(bytes_a, bytes_b): - # Use rawmode "1;8" so that each full byte is interpreted as a value - # instead of the bits in the bytes being interpreted as values. - img_a = Image.frombytes("1", (2, 2), bytes_a, "raw", "1;8") - img_b = Image.frombytes("1", (2, 2), bytes_b, "raw", "1;8") - assert img_a.tobytes() != img_b.tobytes() - assert img_a.im != img_b.im + ): + # Use rawmode "1;8" so that each full byte is interpreted as a value + # instead of the bits in the bytes being interpreted as values. + img_a = Image.frombytes("1", (2, 2), bytes_a, "raw", "1;8") + img_b = Image.frombytes("1", (2, 2), bytes_b, "raw", "1;8") + assert img_a.tobytes() != img_b.tobytes() + assert img_a != img_b @pytest.mark.parametrize("mode", [mode for mode in Image.MODES if mode != "1"]) -def test_not_equal(mode): +def test_not_equal(mode: str) -> None: num_img_bytes = len(Image.new(mode, (2, 2)).tobytes()) data_a = bytes(range(ord("A"), ord("A") + num_img_bytes)) data_b = bytes(range(ord("Z"), ord("Z") - num_img_bytes, -1)) img_a = Image.frombytes(mode, (2, 2), data_a) img_b = Image.frombytes(mode, (2, 2), data_b) assert img_a.tobytes() != img_b.tobytes() - assert img_a.im != img_b.im + assert img_a != img_b @pytest.mark.parametrize("mode", ("RGB", "YCbCr", "HSV", "LAB")) -def test_equal_three_channels_four_bytes(mode): +def test_equal_three_channels_four_bytes(mode: str) -> None: # The "A" and "B" values in LAB images are signed values from -128 to 127, # but we store them as unsigned values from 0 to 255, so we need to use # slightly different input bytes for LAB to get the same output. img_a = Image.new(mode, (1, 1), 0x00B3B231 if mode == "LAB" else 0x00333231) img_b = Image.new(mode, (1, 1), 0xFFB3B231 if mode == "LAB" else 0xFF333231) - assert img_a.tobytes() == b"123" - assert img_b.tobytes() == b"123" - assert img_a.im == img_b.im - + assert img_a.tobytes() == img_b.tobytes() == b"123" + assert img_a == img_b @pytest.mark.parametrize("mode", ("LA", "La", "PA")) -def test_equal_two_channels_four_bytes(mode): +def test_equal_two_channels_four_bytes(mode: str) -> None: + # Test that for LA/La/PA modes, where the data is stored in the 1st and 4th + # byte of each pixel, the middle bytes are masked off for comparison. img_a = Image.new(mode, (1, 1), 0x32000031) img_b = Image.new(mode, (1, 1), 0x32FFFF31) - assert img_a.tobytes() == b"12" - assert img_b.tobytes() == b"12" - assert img_a.im == img_b.im + assert img_a.tobytes() == img_b.tobytes() == b"12" + assert img_a == img_b From a74c03d0a02ba05aa4b7a11fdf92f8d7caf1a77a Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Sun, 20 Sep 2026 22:32:25 +0300 Subject: [PATCH 3/6] Add more tests for equality --- Tests/test_image_equality.py | 75 ++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/Tests/test_image_equality.py b/Tests/test_image_equality.py index 6e5a7c8c212..98cb516154b 100644 --- a/Tests/test_image_equality.py +++ b/Tests/test_image_equality.py @@ -1,12 +1,28 @@ from __future__ import annotations import itertools +from unittest.mock import Mock import pytest from PIL import Image +def _make_rows(width: int, height: int) -> list[bytes]: + """Build test image data of width x height.""" + return [ + bytes((y * (width - 1) + x) % 256 for x in range(width)) for y in range(height) + ] + + +def _frombuffer( + mode: str, size: tuple[int, int], data: bytes, *, stride: int = 0, ystep: int = 1 +) -> Image.Image: + im = Image.frombuffer(mode, size, data, "raw", mode, stride, ystep) + assert im.readonly # Sanity check (that we took the map_buffer path) + return im + + @pytest.mark.parametrize("mode", Image.MODES) def test_equal(mode: str) -> None: num_img_bytes = len(Image.new(mode, (2, 2)).tobytes()) @@ -52,6 +68,7 @@ def test_equal_three_channels_four_bytes(mode: str) -> None: assert img_a.tobytes() == img_b.tobytes() == b"123" assert img_a == img_b + @pytest.mark.parametrize("mode", ("LA", "La", "PA")) def test_equal_two_channels_four_bytes(mode: str) -> None: # Test that for LA/La/PA modes, where the data is stored in the 1st and 4th @@ -60,3 +77,61 @@ def test_equal_two_channels_four_bytes(mode: str) -> None: img_b = Image.new(mode, (1, 1), 0x32FFFF31) assert img_a.tobytes() == img_b.tobytes() == b"12" assert img_a == img_b + + +def test_not_equal_rgbx_padding() -> None: + # Ensure RGBX's last byte is compared, even if it has no image meaning. + img_a = Image.frombytes("RGBX", (1, 1), b"1234") + img_b = Image.frombytes("RGBX", (1, 1), b"123\xff") + assert img_a.tobytes() != img_b.tobytes() + assert img_a != img_b + + +def test_compare_with_other_type() -> None: + im = Image.new("L", (1, 1)) + assert im.im == im.im + assert im.im != 42 + # Check that the other object's comparison method is called. + x = Mock(__eq__=Mock(return_value=True)) + assert im.im == x + assert x.__eq__.called # type: ignore[attr-defined] + + +def test_equal_frombuffer_stride() -> None: + # Test that a buffer-mapped image's padding bytes (stride) + # are not part of the comparison. + width, height, stride = 3, 4, 8 + rows = _make_rows(width, height) + buffer_a = b"".join(row + b"\x00" * (stride - width) for row in rows) + buffer_b = b"".join(row + b"\xff" * (stride - width) for row in rows) + assert buffer_a != buffer_b + img_a = _frombuffer("L", (width, height), buffer_a, stride=stride) + img_b = _frombuffer("L", (width, height), buffer_b, stride=stride) + assert img_a.tobytes() == img_b.tobytes() # Padding bytes disappear + assert img_a == img_b + + +def test_not_equal_frombuffer_stride() -> None: + # Test that differences within strided rows are found, + # even if padding matches. + width, height, stride = 3, 4, 8 + rows = _make_rows(width, height) + padding = b"\xab" * (stride - width) + buffer_a = b"".join(row + padding for row in rows) + rows[height - 1] = bytes(42) + rows[height - 1][1:] + buffer_b = b"".join(row + padding for row in rows) + + img_a = _frombuffer("L", (width, height), buffer_a, stride=stride) + img_b = _frombuffer("L", (width, height), buffer_b, stride=stride) + assert img_a.tobytes() != img_b.tobytes() + assert img_a != img_b + + +def test_equal_frombuffer_ystep() -> None: + # Test that ystep=-1 (rows in reverse order) is handled correctly. + width, height = 3, 4 + rows = _make_rows(width, height) + img_a = _frombuffer("L", (width, height), b"".join(rows)) + img_b = _frombuffer("L", (width, height), b"".join(reversed(rows)), ystep=-1) + assert img_a.tobytes() == img_b.tobytes() + assert img_a == img_b From 69083112df2517e3cab1fed3bb995d8e8e156cdb Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Sun, 20 Sep 2026 16:41:16 +0300 Subject: [PATCH 4/6] Add tp_richcompare handler for Imaging_Type/ImagingCore Co-authored-by: Yay295 --- docs/releasenotes/13.0.0.rst | 5 ++ src/PIL/Image.py | 16 +++- src/_imaging.c | 139 +++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 4 deletions(-) diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 30325e0d9aa..d7573db6ad6 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -193,3 +193,8 @@ Fixed histograms for masked LA, La and PA images When :py:meth:`~PIL.Image.Image.histogram` was given a mask for a two-band image (LA, La or PA), the second band was incorrectly derived from the first band of the input image, rather than the alpha band. + +Faster image comparison +^^^^^^^^^^^^^^^^^^^^^^^ + +Comparing two images with ``==`` no longer copies both images out to ``bytes`` first. diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 873de8607bf..bfdd7c4aa1d 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -754,13 +754,21 @@ def __eq__(self, other: object) -> bool: if self.__class__ is not other.__class__: return False assert isinstance(other, Image) - return ( + if self is other: + return True + # Early-out before realizing palettes. + # ImagingCore does check for mode and size. + if not ( self.mode == other.mode and self.size == other.size and self.info == other.info - and self.getpalette() == other.getpalette() - and self.tobytes() == other.tobytes() - ) + ): + return False + # Realize dirty palettes, if any. + # ImagingCore's compare function checks for palette equality. + self.load() + other.load() + return self.im == other.im def __repr__(self) -> str: return ( diff --git a/src/_imaging.c b/src/_imaging.c index e467777fe19..f13bbc53b06 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -3875,6 +3875,144 @@ static PySequenceMethods image_as_sequence = { (ssizessizeobjargproc)NULL, /*sq_ass_slice*/ }; +/** + * Compare two pixel arrays in the given mode for equality. + * + * @param mode The mode of the pixel arrays. + * @param ysize The number of rows in the pixel arrays. + * @param linesize The number of bytes in each row of the pixel arrays. + * @param pixels_a Pointer to the first array of pixel row arrays. + * @param pixels_b Pointer to the second array of pixel row arrays. + * @return 0 if the pixel arrays are equal, 1 if they are not equal. + */ +static int +_compare_pixels( + const ModeID mode, + const int ysize, + const int linesize, + const char *const *pixels_a, + const char *const *pixels_b +) { + if (ysize <= 0 || linesize <= 0) { + // Pixel arrays without size are always equal. + // Since rows may not have been allocated at all, + // early-out now. + return 0; + } + + // Fortunately, all of the modes that have extra bytes in their pixels + // use four bytes for their pixels. RGBX is deliberately not one of them: + // its fourth byte is compared just like Image.tobytes() includes it. + UINT32 mask = 0xffffffff; + switch (mode) { + case IMAGING_MODE_RGB: + case IMAGING_MODE_YCbCr: + case IMAGING_MODE_HSV: + case IMAGING_MODE_LAB: + // These modes have three channels in four bytes, + // so we have to ignore the last byte. +#ifdef WORDS_BIGENDIAN + mask = 0xffffff00; +#else + mask = 0x00ffffff; +#endif + break; + case IMAGING_MODE_LA: + case IMAGING_MODE_La: + case IMAGING_MODE_PA: + // These modes have two channels in four bytes, + // so we have to ignore the middle two bytes. + mask = 0xff0000ff; + break; + default: + break; + } + + if (mask == 0xffffffff) { + // If we aren't masking anything we can use memcmp. + for (int y = 0; y < ysize; y++) { + if (memcmp(pixels_a[y], pixels_b[y], linesize)) { + return 1; + } + } + } else { + const int xsize = linesize / 4; + for (int y = 0; y < ysize; y++) { + const UINT32 *line_a = (const UINT32 *)pixels_a[y]; + const UINT32 *line_b = (const UINT32 *)pixels_b[y]; + for (int x = 0; x < xsize; x++, line_a++, line_b++) { + if ((*line_a & mask) != (*line_b & mask)) { + return 1; + } + } + } + } + return 0; +} + +/** + * Compare two palettes for equality. + * + * @param palette_a Pointer to the first palette. May be NULL. + * @param palette_b Pointer to the second palette. May be NULL. + * @return 0 if the palettes are equal, 1 if they are not equal. + */ +static int +_compare_palette(const ImagingPalette palette_a, const ImagingPalette palette_b) { + if (palette_a == NULL && palette_b == NULL) { + return 0; + } + if (palette_a == NULL || palette_b == NULL) { + return 1; + } + if (palette_a->size != palette_b->size || palette_a->mode != palette_b->mode) { + return 1; + } + + const char *palette_a_data = (const char *)palette_a->palette; + const char *palette_b_data = (const char *)palette_b->palette; + // 1024 is the hard-coded maximum size of a palette + int linesize = MIN(palette_a->size * 4, 1024); + return _compare_pixels( + palette_a->mode, 1, linesize, &palette_a_data, &palette_b_data + ); +} + +static PyObject * +image_richcompare(PyObject *self, PyObject *other, int op) { + if (op != Py_EQ && op != Py_NE) { + Py_RETURN_NOTIMPLEMENTED; + } + + if (!PyImaging_Check(other)) { + Py_RETURN_NOTIMPLEMENTED; + } + + const Imaging img_a = ((ImagingObject *)self)->image; + const Imaging img_b = ((ImagingObject *)other)->image; + + if (img_a == NULL || img_b == NULL) { + Py_RETURN_NOTIMPLEMENTED; + } + + const int equal = + // Mode and size comparison (cheap) + img_a->mode == img_b->mode && img_a->xsize == img_b->xsize && + img_a->ysize == img_b->ysize && + // Palette comparison + _compare_palette(img_a->palette, img_b->palette) == 0 && + // Pixel comparison + _compare_pixels( + img_a->mode, + img_a->ysize, + img_a->linesize, + (const char *const *)img_a->image, + (const char *const *)img_b->image + ) == 0; + + return PyBool_FromLong(op == Py_EQ ? equal : !equal); +} + /* type description */ static PyTypeObject Imaging_Type = { @@ -3882,6 +4020,7 @@ static PyTypeObject Imaging_Type = { .tp_basicsize = sizeof(ImagingObject), .tp_dealloc = (destructor)_dealloc, .tp_as_sequence = &image_as_sequence, + .tp_richcompare = image_richcompare, .tp_methods = methods, .tp_getset = getsetters, }; From 9f90332a39f5f3803f87cfa8ad564eba610feaf3 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Sun, 20 Sep 2026 23:29:52 +0300 Subject: [PATCH 5/6] Use vectorisable form of masked comparison loop --- src/_imaging.c | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/_imaging.c b/src/_imaging.c index f13bbc53b06..9e52b078b5f 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -3936,14 +3936,30 @@ _compare_pixels( } } } else { + // All modes where we use a mask store four bytes per pixel. const int xsize = linesize / 4; for (int y = 0; y < ysize; y++) { const UINT32 *line_a = (const UINT32 *)pixels_a[y]; const UINT32 *line_b = (const UINT32 *)pixels_b[y]; - for (int x = 0; x < xsize; x++, line_a++, line_b++) { - if ((*line_a & mask) != (*line_b & mask)) { - return 1; - } + // Check the first pixel separately; if it's different, + // we don't need to enter the loop at all. + if ((line_a[0] ^ line_b[0]) & mask) { + return 1; + } + // Autovectorizable form: compute one row at a time, + // and OR together all the differences in that row, + // then see if we saw any differences at all. + // This loop will not bail out in the middle of a row + // (the common case of "entirely different images, + // same size and mode" will have been taken care of + // above in the first-pixel check), but it measures + // to be faster anyhow on modern CPUs. + UINT32 diff = 0; + for (int x = 0; x < xsize; x++) { + diff |= (line_a[x] ^ line_b[x]) & mask; + } + if (diff) { + return 1; } } } From 86025671b9ea681ab6cc00d9ae5c957ef61e42e1 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 21 Sep 2026 14:31:39 +0300 Subject: [PATCH 6/6] Apply suggestions from code review --- src/PIL/Image.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index bfdd7c4aa1d..ca2ba72dbfc 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -756,16 +756,14 @@ def __eq__(self, other: object) -> bool: assert isinstance(other, Image) if self is other: return True - # Early-out before realizing palettes. + # Early-out before loading. # ImagingCore does check for mode and size. - if not ( - self.mode == other.mode - and self.size == other.size - and self.info == other.info + if ( + self.mode != other.mode + or self.size != other.size + or self.info != other.info ): return False - # Realize dirty palettes, if any. - # ImagingCore's compare function checks for palette equality. self.load() other.load() return self.im == other.im