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()
Comment thread
83Gh0st marked this conversation as resolved.


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
45 changes: 39 additions & 6 deletions src/PIL/Image.py
Original file line number Diff line number Diff line change
Expand Up @@ -1052,8 +1052,12 @@ def convert(
should be 4- or 12-tuple containing floating point values.
: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). Note that this is not used when ``matrix`` is supplied.
Available methods are :data:`Dither.NONE` or :data:`Dither.FLOYDSTEINBERG`.
:data:`Dither.FLOYDSTEINBERG` is used by default, except when converting
to an adaptive ``palette``: there, for backwards compatibility with
versions prior to 13.0.0, the default remains :data:`Dither.NONE`; pass
``dither=Dither.FLOYDSTEINBERG`` explicitly to enable dithering in that
case too. Note that ``dither`` is not used when ``matrix`` is supplied.
:param palette: Palette to use when converting from mode "RGB"
to "P". Available palettes are :data:`Palette.WEB` or
:data:`Palette.ADAPTIVE`.
Expand Down Expand Up @@ -1186,6 +1190,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 +1288,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 @@ -1296,8 +1312,13 @@ def quantize(
:py:class:`PIL.Image.Image`.
: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).
Available methods are :data:`Dither.NONE` or :data:`Dither.FLOYDSTEINBERG`.
When a reference ``palette`` is supplied, :data:`Dither.FLOYDSTEINBERG`
is used by default. When no ``palette`` is given (e.g. quantizing to a
new adaptive palette), the default remains :data:`Dither.NONE`, for
backwards compatibility with versions prior to 13.0.0; pass
``dither=Dither.FLOYDSTEINBERG`` explicitly to enable dithering in that
case too.
:returns: A new image
"""

Expand Down Expand Up @@ -1329,7 +1350,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 +1370,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