diff --git a/Tests/test_image_convert.py b/Tests/test_image_convert.py index f57d83dac1a..dad38ae61b1 100644 --- a/Tests/test_image_convert.py +++ b/Tests/test_image_convert.py @@ -188,6 +188,29 @@ def test_trns_l(tmp_path: Path) -> None: im_p.save(f) +def test_p_from_rgb_convert_adaptive_dither() -> None: + # https://github.com/python-pillow/Pillow/issues/5836 + # dither was being silently ignored when converting to an ADAPTIVE + # palette, since self.im.quantize() (used internally) doesn't support + # dithering without an explicit reference palette. + im = hopper("RGB") + + no_dither = im.convert( + "P", palette=Image.Palette.ADAPTIVE, colors=4, dither=Image.Dither.NONE + ) + dither = im.convert( + "P", + palette=Image.Palette.ADAPTIVE, + colors=4, + dither=Image.Dither.FLOYDSTEINBERG, + ) + default = im.convert("P", palette=Image.Palette.ADAPTIVE, colors=4) + + assert dither.tobytes() != no_dither.tobytes() + # unspecified dither must remain backwards compatible with no dithering + assert default.tobytes() == no_dither.tobytes() + + def test_trns_RGB(tmp_path: Path) -> None: im = hopper("RGB") im.info["transparency"] = im.getpixel((0, 0)) diff --git a/Tests/test_image_quantize.py b/Tests/test_image_quantize.py index 8876285605a..48c1d527f44 100644 --- a/Tests/test_image_quantize.py +++ b/Tests/test_image_quantize.py @@ -105,6 +105,40 @@ def test_quantize_dither_diff() -> None: assert dither.tobytes() != nodither.tobytes() +def test_quantize_adaptive_dither_diff() -> None: + # https://github.com/python-pillow/Pillow/issues/5836 + # dither was being silently ignored when no reference palette was given + # (e.g. plain quantize(colors=N)), since self.im.quantize() doesn't + # support dithering directly. + image = hopper() + + no_dither = image.quantize(colors=4, dither=Image.Dither.NONE) + dither = image.quantize(colors=4, dither=Image.Dither.FLOYDSTEINBERG) + default = image.quantize(colors=4) + + assert dither.tobytes() != no_dither.tobytes() + # unspecified dither must remain backwards compatible with no dithering + assert default.tobytes() == no_dither.tobytes() + + +def test_quantize_dither_reference_palette_default_unchanged() -> None: + # The reference-palette path already dithered by default; make sure + # changing the parameter default (Dither.FLOYDSTEINBERG -> None) didn't + # change that. + image = hopper() + with Image.open("Tests/images/caption_6_33_22.png") as palette: + palette_p = palette.convert("P") + + default = image.quantize(palette=palette_p) + explicit_dither = image.quantize( + dither=Image.Dither.FLOYDSTEINBERG, palette=palette_p + ) + no_dither = image.quantize(dither=Image.Dither.NONE, palette=palette_p) + + assert default.tobytes() == explicit_dither.tobytes() + assert default.tobytes() != no_dither.tobytes() + + @pytest.mark.parametrize( "method", (Image.Quantize.MEDIANCUT, Image.Quantize.MAXCOVERAGE) ) diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 45667d9ec07..e9d486b4a52 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -93,6 +93,16 @@ TODO Other changes ============= +Dithering with an adaptive palette +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Previously, the ``dither`` argument was silently ignored when converting or +quantizing to a palette computed from the image itself, e.g. +``im.convert("P", palette=Image.Palette.ADAPTIVE, dither=Image.Dither.FLOYDSTEINBERG)`` +or ``im.quantize(dither=Image.Dither.FLOYDSTEINBERG)`` without a reference +``palette``. Dithering is now applied in these cases as well, matching the +documented behavior. Calls that don't specify ``dither`` are unaffected. + Python 3.15 ^^^^^^^^^^^ diff --git a/src/PIL/Image.py b/src/PIL/Image.py index ddbaec20a05..80d3ec59498 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1186,6 +1186,18 @@ def convert_transparency( new_im.palette = ImagePalette.ImagePalette( "RGB", new_im.im.getpalette("RGB") ) + if dither == Dither.FLOYDSTEINBERG: + # self.im.quantize() above has no dithering support, so the + # requested dither was silently dropped. Now that an adaptive + # palette has been computed, re-convert through it so the + # dithering actually takes effect. + # See https://github.com/python-pillow/Pillow/issues/5836 + im = self.im.convert("P", dither, new_im.im) + new_im = self._new(im) + new_im.palette = ImagePalette.ImagePalette( + "RGB", new_im.im.getpalette("RGB") + ) + assert new_im.palette is not None if delete_trns: # This could possibly happen if we requantize to fewer colors. # The transparency would be totally off in that case. @@ -1272,7 +1284,7 @@ def quantize( method: int | None = None, kmeans: int = 0, palette: Image | None = None, - dither: Dither = Dither.FLOYDSTEINBERG, + dither: Dither | None = None, ) -> Image: """ Convert the image to 'P' mode with the specified number @@ -1297,7 +1309,9 @@ def quantize( :param dither: Dithering method, used when converting from mode "RGB" to "P" or from "RGB" or "L" to "1". Available methods are :data:`Dither.NONE` or :data:`Dither.FLOYDSTEINBERG` - (default). + (default). Prior to this, dithering was silently ignored when no + ``palette`` argument was given (e.g. when quantizing to an adaptive + palette); it is now applied in that case too when requested. :returns: A new image """ @@ -1329,7 +1343,9 @@ def quantize( if self.mode not in {"RGB", "L"}: msg = "only RGB or L mode images can be quantized to a palette" raise ValueError(msg) - im = self.im.convert("P", dither, palette.im) + im = self.im.convert( + "P", Dither.FLOYDSTEINBERG if dither is None else dither, palette.im + ) new_im = self._new(im) assert palette.palette is not None new_im.palette = palette.palette.copy() @@ -1347,6 +1363,16 @@ def quantize( palette_data = im.im.getpalette(mode)[: colors * len(mode)] im.palette = ImagePalette.ImagePalette(mode, palette_data) + if dither == Dither.FLOYDSTEINBERG: + # self.im.quantize() above has no dithering support, so the + # requested dither was silently dropped. Now that a palette has + # been computed, re-convert through it so the requested + # dithering is actually applied. + # See https://github.com/python-pillow/Pillow/issues/5836 + dithered = self._new(self.im.convert("P", dither, im.im)) + dithered.palette = im.palette + return dithered + return im def copy(self) -> Image: