diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 54f62d5c628..3a9ca3b4e1c 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,37 @@ 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", (1, 2)) + im.putpalette((0, 0, 0, 0, 255, 0, 0, 128), "RGBA") + im.putpixel((0, 1), 1) + + result = ImageOps.invert(im) + + palette = result.getpalette("RGBA") + assert palette is not None + assert palette == [255, 255, 255, 0, 0, 255, 255, 128] + + +def test_palette_histogram_operation_not_supported() -> None: + 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 55d400e1d14..81b7021ca61 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -58,11 +58,28 @@ 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) + + # 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] + 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 +672,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 +695,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 +712,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