Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions Tests/test_image_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
34 changes: 34 additions & 0 deletions Tests/test_image_quantize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment thread
radarhere marked this conversation as resolved.
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)
)
Expand Down
10 changes: 10 additions & 0 deletions docs/releasenotes/13.0.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
^^^^^^^^^^^

Expand Down
32 changes: 29 additions & 3 deletions src/PIL/Image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
"""

Expand Down Expand Up @@ -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()
Expand All @@ -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:
Expand Down