Skip to content
Open
29 changes: 29 additions & 0 deletions Tests/test_imageops.py
Original file line number Diff line number Diff line change
Expand Up @@ -604,3 +604,32 @@ 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)


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)
1 change: 1 addition & 0 deletions docs/reference/ImageOps.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions src/PIL/ImageOps.py
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,70 @@ 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you want to explain a bit where this logic comes from? Why value > 233, why quadrant in (0, 3), etc.?



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:
"""
Reduce the number of bits for each color channel.
Expand Down
Loading