diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 63cd0e4d4a9..ad533b54832 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -604,3 +604,20 @@ 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("size", (2, 4)) +def test_dither_primary(size: int) -> None: + im = Image.new("RGB", (size, size), (200, 100, 50)) + out = ImageOps.dither_primary(im) + + expected = Image.new("RGB", (size, size), (255, 0, 0)) + assert_image_equal(out, expected) + + +def test_dither_primary_non_rgb() -> None: + im = Image.new("L", (2, 2), 100) + out = ImageOps.dither_primary(im) + + expected = Image.new("RGB", (2, 2)) + assert_image_equal(out, expected) diff --git a/docs/reference/ImageOps.rst b/docs/reference/ImageOps.rst index 1ecff09f000..aba9dce4c66 100644 --- a/docs/reference/ImageOps.rst +++ b/docs/reference/ImageOps.rst @@ -13,6 +13,7 @@ only work on L and RGB images. .. autofunction:: autocontrast .. 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 42b10bd7bc8..75e3a3884ee 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -644,6 +644,62 @@ 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. + """ + if image.mode != "RGB": + image = image.convert("RGB") + + 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: """ Reduce the number of bits for each color channel.