From f3c13ca66d463bee892e26b4c2f6e2996bb6cc0c Mon Sep 17 00:00:00 2001 From: RavSinghChandan Date: Sat, 10 Oct 2026 18:54:54 +0530 Subject: [PATCH 1/5] Apply invert, posterize and solarize to the palette of a P image --- Tests/test_imageops.py | 35 +++++++++++++++++++++++++++++++++++ src/PIL/ImageOps.py | 27 ++++++++++++++++++++------- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 54f62d5c628..116f6a5ea7a 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -11,6 +11,10 @@ hopper, ) +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + class Deformer(ImageOps.SupportsGetMesh): def getmesh( @@ -627,3 +631,34 @@ def test_autocontrast_preserve_one_color(color: tuple[int, int, int]) -> None: img, cutoff=10, preserve_tone=True ) # single color 10 cutoff assert_image_equal(img, out) + + +@pytest.mark.parametrize( + "operation", + ( + ImageOps.invert, + lambda im: ImageOps.posterize(im, 3), + ImageOps.solarize, + ), +) +def test_palette_operation(operation: Callable[[Image.Image], Image.Image]) -> None: + im = hopper("P") + result = operation(im) + + assert result.mode == "P" + assert_image_equal(result.convert("RGB"), operation(im.convert("RGB"))) + + +def test_palette_operation_keeps_alpha() -> None: + im = Image.new("P", (2, 1)) + im.putpalette((0, 0, 0, 0, 255, 0, 0, 128), "RGBA") + im.putpixel((1, 0), 1) + + result = ImageOps.invert(im) + + assert result.getpalette("RGBA")[:8] == [255, 255, 255, 0, 0, 255, 255, 128] + + +def test_palette_histogram_operation_not_supported() -> None: + with pytest.raises(NotImplementedError): + ImageOps.autocontrast(hopper("P")) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 55d400e1d14..f1c25c0aba9 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -58,11 +58,24 @@ def _color( return color -def _lut(image: Image.Image, lut: list[int]) -> Image.Image: +def _lut(image: Image.Image, lut: list[int], palette: bool = False) -> Image.Image: if image.mode == "P": - # FIXME: apply to lookup table, not image data - msg = "mode P support coming soon" - raise NotImplementedError(msg) + if not palette: + # The lookup table was built from the palette indexes, not colors + msg = "mode P support coming soon" + raise NotImplementedError(msg) + # A per-pixel operation can be applied to the palette colors instead + assert image.palette is not None + palette_mode = image.palette.mode + channels = len(palette_mode) + colors = image.getpalette(palette_mode) + assert colors is not None + im = image.copy() + im.putpalette( + [lut[v] if i % channels < 3 else v for i, v in enumerate(colors)], + palette_mode, + ) + return im elif image.mode in ("L", "RGB"): if image.mode == "RGB" and len(lut) == 256: lut = lut + lut + lut @@ -655,7 +668,7 @@ def invert(image: Image.Image) -> Image.Image: :return: An image. """ lut = list(range(255, -1, -1)) - return image.point(lut) if image.mode == "1" else _lut(image, lut) + return image.point(lut) if image.mode == "1" else _lut(image, lut, palette=True) def mirror(image: Image.Image) -> Image.Image: @@ -678,7 +691,7 @@ def posterize(image: Image.Image, bits: int) -> Image.Image: """ mask = ~(2 ** (8 - bits) - 1) lut = [i & mask for i in range(256)] - return _lut(image, lut) + return _lut(image, lut, palette=True) def solarize(image: Image.Image, threshold: int = 128) -> Image.Image: @@ -695,7 +708,7 @@ def solarize(image: Image.Image, threshold: int = 128) -> Image.Image: lut.append(i) else: lut.append(255 - i) - return _lut(image, lut) + return _lut(image, lut, palette=True) @overload From 5b407374a2be86a3d2b3edd64a2ad06bbfa01925 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 11 Oct 2026 12:45:04 +1100 Subject: [PATCH 2/5] Lint fix --- Tests/test_imageops.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 116f6a5ea7a..3ca088b4fdd 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -656,7 +656,9 @@ def test_palette_operation_keeps_alpha() -> None: result = ImageOps.invert(im) - assert result.getpalette("RGBA")[:8] == [255, 255, 255, 0, 0, 255, 255, 128] + palette = result.getpalette("RGBA") + assert palette is not None + assert palette[:8] == [255, 255, 255, 0, 0, 255, 255, 128] def test_palette_histogram_operation_not_supported() -> None: From 0375e495e15d8592f154f63f65cadab43ed6a6ae Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 11 Oct 2026 14:07:42 +1100 Subject: [PATCH 3/5] Simplify code --- src/PIL/ImageOps.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index f1c25c0aba9..0ec595d1ae2 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -64,13 +64,14 @@ def _lut(image: Image.Image, lut: list[int], palette: bool = False) -> Image.Ima # The lookup table was built from the palette indexes, not colors msg = "mode P support coming soon" raise NotImplementedError(msg) - # A per-pixel operation can be applied to the palette colors instead + # A per-pixel operation can be applied to the palette colors + colors = image.getpalette(None) + assert colors is not None + im = image.copy() + assert image.palette is not None palette_mode = image.palette.mode channels = len(palette_mode) - colors = image.getpalette(palette_mode) - assert colors is not None - im = image.copy() im.putpalette( [lut[v] if i % channels < 3 else v for i, v in enumerate(colors)], palette_mode, From 2bff9fd6304800063d80a9f9634ce97a1354e4d9 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 11 Oct 2026 14:32:48 +1100 Subject: [PATCH 4/5] Only exclude A channel in RGBA, not K in CMYK --- src/PIL/ImageOps.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 0ec595d1ae2..4cbc8c13a21 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -73,7 +73,10 @@ def _lut(image: Image.Image, lut: list[int], palette: bool = False) -> Image.Ima palette_mode = image.palette.mode channels = len(palette_mode) im.putpalette( - [lut[v] if i % channels < 3 else v for i, v in enumerate(colors)], + [ + v if palette_mode[i % channels] == "A" else lut[v] + for i, v in enumerate(colors) + ], palette_mode, ) return im From 9751e0fdcc5b193760a1e1df9d181df740b45643 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 16 Oct 2026 11:08:26 +1100 Subject: [PATCH 5/5] Check all palette data --- Tests/test_imageops.py | 11 ++++++----- src/PIL/ImageOps.py | 12 ++++++------ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 3ca088b4fdd..3a9ca3b4e1c 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -650,17 +650,18 @@ def test_palette_operation(operation: Callable[[Image.Image], Image.Image]) -> N def test_palette_operation_keeps_alpha() -> None: - im = Image.new("P", (2, 1)) + im = Image.new("P", (1, 2)) im.putpalette((0, 0, 0, 0, 255, 0, 0, 128), "RGBA") - im.putpixel((1, 0), 1) + im.putpixel((0, 1), 1) result = ImageOps.invert(im) palette = result.getpalette("RGBA") assert palette is not None - assert palette[:8] == [255, 255, 255, 0, 0, 255, 255, 128] + assert palette == [255, 255, 255, 0, 0, 255, 255, 128] def test_palette_histogram_operation_not_supported() -> None: - with pytest.raises(NotImplementedError): - ImageOps.autocontrast(hopper("P")) + im = hopper("P") + with pytest.raises(NotImplementedError, match="mode P support coming soon"): + ImageOps.autocontrast(im) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 4cbc8c13a21..81b7021ca61 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -64,14 +64,14 @@ def _lut(image: Image.Image, lut: list[int], palette: bool = False) -> Image.Ima # The lookup table was built from the palette indexes, not colors msg = "mode P support coming soon" raise NotImplementedError(msg) - # A per-pixel operation can be applied to the palette colors - colors = image.getpalette(None) - assert colors is not None - im = image.copy() - assert image.palette is not None - palette_mode = image.palette.mode + # The lookup table can be applied to the palette colors + im = image.copy() + assert im.palette is not None + palette_mode = im.palette.mode channels = len(palette_mode) + colors = im.getpalette(None) + assert colors is not None im.putpalette( [ v if palette_mode[i % channels] == "A" else lut[v]