diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 63cd0e4d4..0ea3e8660 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 1ecff09f0..6c37a8d25 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 42b10bd7b..9e5af0ed1 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: """