From 8cb0ce545f4d557c4faa5d2ab30d25be60e462be Mon Sep 17 00:00:00 2001 From: Marcelo-MConti Date: Fri, 12 Dec 2025 19:09:47 -0300 Subject: [PATCH 01/10] Add primary color dithering to ImageOps --- Tests/test_imageops.py | 31 +++++++++++++++++++ docs/reference/ImageOps.rst | 1 + src/PIL/ImageOps.py | 62 +++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 63cd0e4d4a9..0ea3e8660c7 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -604,3 +604,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) + +from PIL import Image, ImageOps + + +def test_dither_primary_returns_image(): + im = Image.new("RGB", (4, 4), (128, 128, 128)) + out = ImageOps.dither_primary(im) + + assert isinstance(out, Image.Image) + assert out.size == im.size + assert out.mode == "RGB" + + +def test_dither_primary_uses_only_primary_colors(): + im = Image.new("RGB", (4, 4), (200, 100, 50)) + out = ImageOps.dither_primary(im) + + pixels = out.load() + for x in range(out.width): + for y in range(out.height): + r, g, b = pixels[x, y] + assert r in (0, 255) + assert g in (0, 255) + assert b in (0, 255) + + +def test_dither_primary_small_image(): + im = Image.new("RGB", (2, 2), (255, 0, 0)) + out = ImageOps.dither_primary(im) + + assert out.size == (2, 2) diff --git a/docs/reference/ImageOps.rst b/docs/reference/ImageOps.rst index 1ecff09f000..6c37a8d2569 100644 --- a/docs/reference/ImageOps.rst +++ b/docs/reference/ImageOps.rst @@ -11,6 +11,7 @@ only work on L and RGB images. .. versionadded:: 1.1.3 .. autofunction:: autocontrast +.. autofunction:: dither_primary .. autofunction:: colorize .. autofunction:: crop .. autofunction:: scale diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 42b10bd7bc8..9e5af0ed10e 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -643,6 +643,68 @@ def mirror(image: Image.Image) -> Image.Image: """ return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) +def _dither_saturation(value: float, quadrant: int) -> int: + if value > 233: + return 255 + if value > 159: + return 255 if quadrant != 1 else 0 + if value > 95: + return 255 if quadrant in (0, 3) else 0 + if value > 32: + return 255 if quadrant == 1 else 0 + return 0 + +def dither_primary(image: Image.Image) -> Image.Image: + """ + Reduce the image to primary colors and apply ordered dithering. + + This operation first reduces each RGB channel to its primary values + (0 or 255), then applies a 2x2 ordered dithering pattern based on the + average color intensity. + + :param image: The image to process. + :return: An image. + """ + image = image.convert("RGB") + width, height = image.size + + src = image.load() + out = Image.new("RGB", (width, height)) + dst = out.load() + + # Step 1: primary color reduction + for x in range(width): + for y in range(height): + r, g, b = src[x, y] + src[x, y] = ( + 255 if r > 127 else 0, + 255 if g > 127 else 0, + 255 if b > 127 else 0, + ) + + # Step 2: ordered dithering (2x2 blocks) + for x in range(0, width - 1, 2): + for y in range(0, height - 1, 2): + p1 = src[x, y] + p2 = src[x, y + 1] + p3 = src[x + 1, y] + p4 = src[x + 1, y + 1] + + red = (p1[0] + p2[0] + p3[0] + p4[0]) / 4 + green = (p1[1] + p2[1] + p3[1] + p4[1]) / 4 + blue = (p1[2] + p2[2] + p3[2] + p4[2]) / 4 + + r = [_dither_saturation(red, q) for q in range(4)] + g = [_dither_saturation(green, q) for q in range(4)] + b = [_dither_saturation(blue, q) for q in range(4)] + + dst[x, y] = (r[0], g[0], b[0]) + dst[x, y + 1] = (r[1], g[1], b[1]) + dst[x + 1, y] = (r[2], g[2], b[2]) + dst[x + 1, y + 1] = (r[3], g[3], b[3]) + + return out + def posterize(image: Image.Image, bits: int) -> Image.Image: """ From 7b59e6f6b3146c99074c8c925aab333cc7c7d6bf Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 22:25:12 +0000 Subject: [PATCH 02/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- Tests/test_imageops.py | 3 ++- src/PIL/ImageOps.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 0ea3e8660c7..485a8e44385 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -605,7 +605,8 @@ def test_autocontrast_preserve_one_color(color: tuple[int, int, int]) -> None: ) # single color 10 cutoff assert_image_equal(img, out) -from PIL import Image, ImageOps + +from PIL import ImageOps def test_dither_primary_returns_image(): diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 9e5af0ed10e..001d7629c8b 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -643,6 +643,7 @@ def mirror(image: Image.Image) -> Image.Image: """ return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + def _dither_saturation(value: float, quadrant: int) -> int: if value > 233: return 255 @@ -654,6 +655,7 @@ def _dither_saturation(value: float, quadrant: int) -> int: return 255 if quadrant == 1 else 0 return 0 + def dither_primary(image: Image.Image) -> Image.Image: """ Reduce the image to primary colors and apply ordered dithering. From e6923509deff298da3d238947c18f52960eab40b Mon Sep 17 00:00:00 2001 From: Marcelo-MConti Date: Fri, 12 Dec 2025 19:33:38 -0300 Subject: [PATCH 03/10] Fix minor issue in dither_primary implementation --- Tests/test_imageops.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 485a8e44385..0904aa012e2 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -605,10 +605,6 @@ def test_autocontrast_preserve_one_color(color: tuple[int, int, int]) -> None: ) # single color 10 cutoff assert_image_equal(img, out) - -from PIL import ImageOps - - def test_dither_primary_returns_image(): im = Image.new("RGB", (4, 4), (128, 128, 128)) out = ImageOps.dither_primary(im) From 4d9f78ee6f89fa8da7f2e44fd9b6eb39bf5e1e38 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 22:40:10 +0000 Subject: [PATCH 04/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- Tests/test_imageops.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 0904aa012e2..0b0bd129571 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -605,6 +605,7 @@ def test_autocontrast_preserve_one_color(color: tuple[int, int, int]) -> None: ) # single color 10 cutoff assert_image_equal(img, out) + def test_dither_primary_returns_image(): im = Image.new("RGB", (4, 4), (128, 128, 128)) out = ImageOps.dither_primary(im) From 33a128c515271b8b477c1a1151db1c5c2f2122d9 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 13 Dec 2025 10:07:44 +1100 Subject: [PATCH 05/10] Added type hints --- Tests/test_imageops.py | 15 ++++++++++----- src/PIL/ImageOps.py | 24 ++++++++++++++++-------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 0b0bd129571..94370fa247f 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -606,7 +606,7 @@ def test_autocontrast_preserve_one_color(color: tuple[int, int, int]) -> None: assert_image_equal(img, out) -def test_dither_primary_returns_image(): +def test_dither_primary_returns_image() -> None: im = Image.new("RGB", (4, 4), (128, 128, 128)) out = ImageOps.dither_primary(im) @@ -615,20 +615,25 @@ def test_dither_primary_returns_image(): assert out.mode == "RGB" -def test_dither_primary_uses_only_primary_colors(): +def test_dither_primary_uses_only_primary_colors() -> None: im = Image.new("RGB", (4, 4), (200, 100, 50)) out = ImageOps.dither_primary(im) - pixels = out.load() + px = out.load() + assert px is not None + for x in range(out.width): for y in range(out.height): - r, g, b = pixels[x, y] + value = px[x, y] + assert isinstance(value, tuple) + + r, g, b = value assert r in (0, 255) assert g in (0, 255) assert b in (0, 255) -def test_dither_primary_small_image(): +def test_dither_primary_small_image() -> None: im = Image.new("RGB", (2, 2), (255, 0, 0)) out = ImageOps.dither_primary(im) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 001d7629c8b..3e692abd167 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -675,9 +675,12 @@ def dither_primary(image: Image.Image) -> Image.Image: dst = out.load() # Step 1: primary color reduction + assert src is not None for x in range(width): for y in range(height): - r, g, b = src[x, y] + px = src[x, y] + assert isinstance(px, tuple) + r, g, b = px src[x, y] = ( 255 if r > 127 else 0, 255 if g > 127 else 0, @@ -685,6 +688,7 @@ def dither_primary(image: Image.Image) -> Image.Image: ) # Step 2: ordered dithering (2x2 blocks) + assert dst is not None for x in range(0, width - 1, 2): for y in range(0, height - 1, 2): p1 = src[x, y] @@ -692,18 +696,22 @@ def dither_primary(image: Image.Image) -> Image.Image: p3 = src[x + 1, y] p4 = src[x + 1, y + 1] + assert isinstance(p1, tuple) + assert isinstance(p2, tuple) + assert isinstance(p3, tuple) + assert isinstance(p4, tuple) red = (p1[0] + p2[0] + p3[0] + p4[0]) / 4 green = (p1[1] + p2[1] + p3[1] + p4[1]) / 4 blue = (p1[2] + p2[2] + p3[2] + p4[2]) / 4 - r = [_dither_saturation(red, q) for q in range(4)] - g = [_dither_saturation(green, q) for q in range(4)] - b = [_dither_saturation(blue, q) for q in range(4)] + r1 = [_dither_saturation(red, q) for q in range(4)] + g1 = [_dither_saturation(green, q) for q in range(4)] + b1 = [_dither_saturation(blue, q) for q in range(4)] - dst[x, y] = (r[0], g[0], b[0]) - dst[x, y + 1] = (r[1], g[1], b[1]) - dst[x + 1, y] = (r[2], g[2], b[2]) - dst[x + 1, y + 1] = (r[3], g[3], b[3]) + dst[x, y] = (r1[0], g1[0], b1[0]) + dst[x, y + 1] = (r1[1], g1[1], b1[1]) + dst[x + 1, y] = (r1[2], g1[2], b1[2]) + dst[x + 1, y + 1] = (r1[3], g1[3], b1[3]) return out From 2a006446afd089eaa2f8807124ea5f7d25df4820 Mon Sep 17 00:00:00 2001 From: Marcelo-MConti Date: Sun, 14 Dec 2025 11:34:09 -0300 Subject: [PATCH 06/10] Update Tests/test_imageops.py Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- Tests/test_imageops.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 0b0bd129571..12db93a226e 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -606,7 +606,7 @@ def test_autocontrast_preserve_one_color(color: tuple[int, int, int]) -> None: assert_image_equal(img, out) -def test_dither_primary_returns_image(): +def test_dither_primary_returns_image() -> None: im = Image.new("RGB", (4, 4), (128, 128, 128)) out = ImageOps.dither_primary(im) From aee5905539b683cda5b4d2b83b1f6a5b1458524f Mon Sep 17 00:00:00 2001 From: Marcelo-MConti Date: Sun, 14 Dec 2025 11:34:22 -0300 Subject: [PATCH 07/10] Update Tests/test_imageops.py Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- Tests/test_imageops.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 12db93a226e..48aa0d53e2a 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -615,7 +615,7 @@ def test_dither_primary_returns_image() -> None: assert out.mode == "RGB" -def test_dither_primary_uses_only_primary_colors(): +def test_dither_primary_uses_only_primary_colors() -> None: im = Image.new("RGB", (4, 4), (200, 100, 50)) out = ImageOps.dither_primary(im) From a2354b4cef2e19e516e14beba4ec8560457f92cf Mon Sep 17 00:00:00 2001 From: Marcelo-MConti Date: Sun, 14 Dec 2025 11:34:32 -0300 Subject: [PATCH 08/10] Update Tests/test_imageops.py Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- Tests/test_imageops.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 48aa0d53e2a..b1a7b648427 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -628,7 +628,7 @@ def test_dither_primary_uses_only_primary_colors() -> None: assert b in (0, 255) -def test_dither_primary_small_image(): +def test_dither_primary_small_image() -> None: im = Image.new("RGB", (2, 2), (255, 0, 0)) out = ImageOps.dither_primary(im) From 9ff5a72db8e38bc337898ad5846d34c005d78214 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 15 Dec 2025 15:18:22 +1100 Subject: [PATCH 09/10] Only convert image if it is not RGB --- src/PIL/ImageOps.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 3e692abd167..672f2cfb4ed 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -667,7 +667,8 @@ def dither_primary(image: Image.Image) -> Image.Image: :param image: The image to process. :return: An image. """ - image = image.convert("RGB") + if image.mode != "RGB": + image = image.convert("RGB") width, height = image.size src = image.load() From f58097755e23a1083f06ee25e8e4a90c18d1341a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 15 Dec 2025 15:38:12 +1100 Subject: [PATCH 10/10] Process image band by band --- Tests/test_imageops.py | 35 +++++------------- docs/reference/ImageOps.rst | 2 +- src/PIL/ImageOps.py | 73 ++++++++++++++----------------------- 3 files changed, 38 insertions(+), 72 deletions(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 94370fa247f..ad533b54832 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -606,35 +606,18 @@ def test_autocontrast_preserve_one_color(color: tuple[int, int, int]) -> None: assert_image_equal(img, out) -def test_dither_primary_returns_image() -> None: - im = Image.new("RGB", (4, 4), (128, 128, 128)) +@pytest.mark.parametrize("size", (2, 4)) +def test_dither_primary(size: int) -> None: + im = Image.new("RGB", (size, size), (200, 100, 50)) out = ImageOps.dither_primary(im) - assert isinstance(out, Image.Image) - assert out.size == im.size - assert out.mode == "RGB" + expected = Image.new("RGB", (size, size), (255, 0, 0)) + assert_image_equal(out, expected) -def test_dither_primary_uses_only_primary_colors() -> None: - im = Image.new("RGB", (4, 4), (200, 100, 50)) +def test_dither_primary_non_rgb() -> None: + im = Image.new("L", (2, 2), 100) out = ImageOps.dither_primary(im) - px = out.load() - assert px is not None - - for x in range(out.width): - for y in range(out.height): - value = px[x, y] - assert isinstance(value, tuple) - - r, g, b = value - assert r in (0, 255) - assert g in (0, 255) - assert b in (0, 255) - - -def test_dither_primary_small_image() -> None: - im = Image.new("RGB", (2, 2), (255, 0, 0)) - out = ImageOps.dither_primary(im) - - assert out.size == (2, 2) + expected = Image.new("RGB", (2, 2)) + assert_image_equal(out, expected) diff --git a/docs/reference/ImageOps.rst b/docs/reference/ImageOps.rst index 6c37a8d2569..aba9dce4c66 100644 --- a/docs/reference/ImageOps.rst +++ b/docs/reference/ImageOps.rst @@ -11,9 +11,9 @@ only work on L and RGB images. .. versionadded:: 1.1.3 .. autofunction:: autocontrast -.. autofunction:: dither_primary .. autofunction:: colorize .. autofunction:: crop +.. autofunction:: dither_primary .. autofunction:: scale .. autoclass:: SupportsGetMesh :show-inheritance: diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 672f2cfb4ed..75e3a3884ee 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -669,52 +669,35 @@ def dither_primary(image: Image.Image) -> Image.Image: """ if image.mode != "RGB": image = image.convert("RGB") - width, height = image.size - - src = image.load() - out = Image.new("RGB", (width, height)) - dst = out.load() - - # Step 1: primary color reduction - assert src is not None - for x in range(width): - for y in range(height): - px = src[x, y] - assert isinstance(px, tuple) - r, g, b = px - src[x, y] = ( - 255 if r > 127 else 0, - 255 if g > 127 else 0, - 255 if b > 127 else 0, - ) - - # Step 2: ordered dithering (2x2 blocks) - assert dst is not None - for x in range(0, width - 1, 2): - for y in range(0, height - 1, 2): - p1 = src[x, y] - p2 = src[x, y + 1] - p3 = src[x + 1, y] - p4 = src[x + 1, y + 1] - - assert isinstance(p1, tuple) - assert isinstance(p2, tuple) - assert isinstance(p3, tuple) - assert isinstance(p4, tuple) - red = (p1[0] + p2[0] + p3[0] + p4[0]) / 4 - green = (p1[1] + p2[1] + p3[1] + p4[1]) / 4 - blue = (p1[2] + p2[2] + p3[2] + p4[2]) / 4 - - r1 = [_dither_saturation(red, q) for q in range(4)] - g1 = [_dither_saturation(green, q) for q in range(4)] - b1 = [_dither_saturation(blue, q) for q in range(4)] - - dst[x, y] = (r1[0], g1[0], b1[0]) - dst[x, y + 1] = (r1[1], g1[1], b1[1]) - dst[x + 1, y] = (r1[2], g1[2], b1[2]) - dst[x + 1, y + 1] = (r1[3], g1[3], b1[3]) - return out + bands = [] + for band in image.split(): + # Step 1: primary color reduction + band = band.point(lambda x: 255 if x > 127 else 0) + bands.append(band) + + # Step 2: ordered dithering (2x2 blocks) + px = band.load() + assert px is not None + for x in range(0, band.width - 1, 2): + for y in range(0, band.height - 1, 2): + p1 = px[x, y] + p2 = px[x, y + 1] + p3 = px[x + 1, y] + p4 = px[x + 1, y + 1] + + assert isinstance(p1, (int, float)) + assert isinstance(p2, (int, float)) + assert isinstance(p3, (int, float)) + assert isinstance(p4, (int, float)) + + value = (p1 + p2 + p3 + p4) / 4 + + px[x, y] = _dither_saturation(value, 0) + px[x, y + 1] = _dither_saturation(value, 1) + px[x + 1, y] = _dither_saturation(value, 2) + px[x + 1, y + 1] = _dither_saturation(value, 3) + return Image.merge("RGB", bands) def posterize(image: Image.Image, bits: int) -> Image.Image: