From 4b5a6e34331919b0d866167a6a4ae9fa26e30d14 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Thu, 10 Sep 2026 12:20:14 +0300 Subject: [PATCH 1/2] Speed up ImageChops.offset(), and make it correct for 16-bpp images Co-authored-by: Andrew Murray --- Tests/test_imagechops.py | 20 +++++++++++++++ docs/releasenotes/13.0.0.rst | 5 ++++ src/libImaging/Offset.c | 49 ++++++++++++++++-------------------- 3 files changed, 46 insertions(+), 28 deletions(-) diff --git a/Tests/test_imagechops.py b/Tests/test_imagechops.py index 7e3ac3d3e8e..8ed8e34ed81 100644 --- a/Tests/test_imagechops.py +++ b/Tests/test_imagechops.py @@ -284,6 +284,26 @@ def test_offset() -> None: assert ImageChops.offset(im, xoffset) == ImageChops.offset(im, xoffset, xoffset) +@pytest.mark.parametrize("mode", Image.MODES) +def test_offset_modes(mode: str) -> None: + # Arrange + im = hopper(mode) + assert_image_equal(ImageChops.offset(im, 0, 0), im) # check no-op + + xoffset = 42 + yoffset = 67 + + # Act + new = ImageChops.offset(im, xoffset, yoffset) + + # Assert + w, h = im.size + for x in range(w): + for y in range(h): + wrapped_coord = ((x + xoffset) % w, (y + yoffset) % h) + assert new.getpixel(wrapped_coord) == im.getpixel((x, y)) + + @pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0))) def test_offset_zero_size(size: tuple[int, int]) -> None: im = Image.new("RGB", size) diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 6962851c20f..b299c802eb6 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -127,3 +127,8 @@ to help projects prepare for 3.15, and to ensure Pillow could be used immediately at the release of 3.15.0 final (2026-10-01, :pep:`790`). Pillow 13.0.0 now officially supports Python 3.15. + +Fixed ImageChops.offset() for 16-bit images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:meth:`~PIL.ImageChops.offset` now works correctly for 16-bit-per-channel images. diff --git a/src/libImaging/Offset.c b/src/libImaging/Offset.c index 720fa90e7f5..48c60b3c4f2 100644 --- a/src/libImaging/Offset.c +++ b/src/libImaging/Offset.c @@ -24,53 +24,46 @@ */ Imaging ImagingOffset(Imaging im, int xoffset, int yoffset) { - int x, y; - Imaging imOut; - if (!im) { return (Imaging)ImagingError_ModeError(); } - imOut = ImagingNewDirty(im->mode, im->xsize, im->ysize); + int xsize = im->xsize, ysize = im->ysize; + + Imaging imOut = ImagingNewDirty(im->mode, xsize, ysize); if (!imOut) { return NULL; } ImagingCopyPalette(imOut, im); - /* make offsets positive to avoid negative coordinates */ - if (im->xsize == 0 || im->ysize == 0) { + if (xsize == 0 || ysize == 0) { return imOut; } - xoffset %= im->xsize; - xoffset = im->xsize - xoffset; + + xoffset %= xsize; if (xoffset < 0) { - xoffset += im->xsize; + xoffset += xsize; } - yoffset %= im->ysize; - yoffset = im->ysize - yoffset; + yoffset %= ysize; if (yoffset < 0) { - yoffset += im->ysize; + yoffset += ysize; } - // yi depends only on y, so compute it (and both row pointers) once per - // row instead of redoing the modulo and pointer chase for every x. -#define OFFSET(type, image) \ - for (y = 0; y < im->ysize; y++) { \ - int yi = (y + yoffset) % im->ysize; \ - type *restrict in = im->image[yi]; \ - type *restrict out = imOut->image[y]; \ - for (x = 0; x < im->xsize; x++) { \ - int xi = (x + xoffset) % im->xsize; \ - out[x] = in[xi]; \ - } \ - } + int head = xoffset * im->pixelsize; + int tail = im->linesize - head; - if (im->image8) { - OFFSET(UINT8, image8) - } else { - OFFSET(INT32, image32) + // Restrict safe: im is read-only, imOut is write-only and a new allocation. + for (int y = 0; y < ysize; y++) { + int yi = y - yoffset; + if (yi < 0) { + yi += ysize; + } + const char *restrict in = im->image[yi]; + char *restrict out = imOut->image[y]; + memcpy(out, in + tail, head); + memcpy(out + head, in, tail); } return imOut; From ed627f5e784a35d4be3ac3251e068148414c73b1 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Thu, 10 Sep 2026 14:22:10 +0300 Subject: [PATCH 2/2] Adjust offset test xoffset/yoffset --- Tests/test_imagechops.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/test_imagechops.py b/Tests/test_imagechops.py index 8ed8e34ed81..10b4c50d394 100644 --- a/Tests/test_imagechops.py +++ b/Tests/test_imagechops.py @@ -290,8 +290,8 @@ def test_offset_modes(mode: str) -> None: im = hopper(mode) assert_image_equal(ImageChops.offset(im, 0, 0), im) # check no-op - xoffset = 42 - yoffset = 67 + xoffset = 45 + yoffset = 20 # Act new = ImageChops.offset(im, xoffset, yoffset)