diff --git a/Tests/images/hopper_jjn.pbm b/Tests/images/hopper_jjn.pbm new file mode 100644 index 00000000000..4133f242214 --- /dev/null +++ b/Tests/images/hopper_jjn.pbm @@ -0,0 +1,35 @@ +P1 +# JJN-dithered crop (24, 24, 56, 56) of hopper.ppm +32 32 +0 0 1 1 1 1 1 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 +1 1 1 1 1 1 1 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 +1 1 1 1 1 1 1 1 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 +1 1 1 1 1 1 1 1 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 +1 1 1 1 1 1 1 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 +1 1 1 1 1 1 1 1 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 +1 1 1 1 1 1 1 1 1 0 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 1 0 0 1 0 0 0 +1 1 1 0 1 1 1 1 0 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 1 1 0 0 1 0 0 1 +1 1 1 1 1 1 1 1 0 0 1 1 1 1 1 1 0 0 1 0 0 1 0 0 0 0 0 0 0 0 0 0 +1 1 1 1 1 1 1 1 0 1 1 1 1 1 1 1 0 1 1 0 1 0 1 1 0 0 1 1 0 0 1 0 +1 1 1 0 1 1 1 1 0 1 1 1 1 1 1 1 1 1 1 0 0 0 0 1 0 0 0 0 0 1 0 0 +1 1 1 1 1 1 1 1 0 0 1 1 1 1 1 0 0 1 0 1 1 0 0 0 1 1 1 0 1 1 0 0 +1 1 1 1 1 1 1 1 0 0 1 1 1 1 1 1 0 1 0 0 1 0 1 0 0 0 1 0 1 0 1 0 +1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 0 1 0 0 1 0 1 0 1 0 1 0 1 1 1 +1 1 1 0 1 1 1 1 0 0 1 1 1 1 0 0 1 1 1 0 0 1 0 0 1 1 1 0 1 0 0 0 +1 1 1 1 1 1 1 1 0 0 1 1 1 1 0 1 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 +0 0 1 1 1 1 1 1 0 1 1 1 1 0 0 1 1 1 1 1 1 1 1 0 0 0 0 0 1 0 0 1 +0 1 1 1 1 1 1 1 0 1 1 1 1 0 1 1 0 1 1 1 1 1 0 0 1 1 1 1 1 1 1 0 +1 1 1 1 1 1 1 1 0 0 1 1 0 0 1 1 1 0 1 0 1 0 1 1 1 1 1 1 1 1 1 1 +1 1 1 1 1 1 1 1 0 0 1 1 0 1 0 0 1 1 1 1 1 0 1 0 1 1 0 1 1 1 0 1 +0 1 1 1 0 1 1 1 1 1 1 1 0 1 1 0 1 0 0 0 1 1 1 1 0 1 1 0 0 1 0 0 +0 1 1 1 1 1 1 1 0 0 1 1 0 1 1 1 0 1 0 0 1 0 1 1 1 1 0 0 0 1 0 0 +1 0 1 1 1 1 1 1 0 0 1 1 1 1 0 0 1 1 1 0 0 0 1 0 1 0 1 0 0 1 0 1 +1 1 1 1 1 1 1 1 1 1 1 1 0 1 0 0 1 1 0 1 1 0 0 1 0 0 1 1 0 1 1 0 +1 1 1 1 1 1 1 1 0 0 1 1 0 1 0 1 0 1 0 0 1 1 0 1 1 0 1 0 0 0 0 0 +1 1 1 1 1 1 1 1 0 0 1 1 1 1 0 0 1 1 0 0 0 0 0 1 1 0 0 0 0 0 0 0 +1 1 1 1 1 1 1 1 1 0 1 0 0 0 1 0 1 1 0 1 1 0 0 1 1 0 0 1 0 1 0 0 +1 1 1 1 1 0 1 1 1 0 0 0 0 0 0 1 0 0 0 1 1 1 0 0 0 1 0 0 0 0 0 1 +1 1 1 1 1 1 1 1 0 0 1 1 0 1 0 0 1 0 0 1 0 0 1 1 0 1 1 0 1 0 1 1 +1 1 1 1 1 1 1 0 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 0 1 1 1 1 0 0 +1 1 1 1 1 1 0 0 0 1 0 0 0 1 0 0 1 0 0 1 1 1 0 1 0 0 0 0 0 0 0 1 +1 1 1 1 1 0 0 1 0 0 1 0 0 1 0 0 0 1 0 0 1 0 0 1 1 1 0 0 0 0 1 1 diff --git a/Tests/test_image_dither_jjn.py b/Tests/test_image_dither_jjn.py new file mode 100644 index 00000000000..68e40a3c8a4 --- /dev/null +++ b/Tests/test_image_dither_jjn.py @@ -0,0 +1,49 @@ +"""Jarvis-Judice-Ninke error diffusion for bilevel and palette images.""" + +from __future__ import annotations + +import pytest + +from PIL import Image + +from .helper import assert_image_equal, hopper + + +@pytest.mark.parametrize("size", [(1, 1), (1, 9), (9, 1), (3, 3), (13, 11)]) +@pytest.mark.parametrize("mode", ["L", "RGB"]) +@pytest.mark.parametrize("value", [0, 255]) +def test_jjn_bilevel_solid_image(mode: str, size: tuple[int, int], value: int) -> None: + im = Image.new(mode, size, (value,) * 3 if mode == "RGB" else value) + result = im.convert("1", dither=Image.Dither.JARVIS_JUDICE_NINKE) + assert_image_equal(result, Image.new("1", size, value)) + + +def test_jjn_palette_differs_from_floyd_steinberg() -> None: + size = (33, 15) + im = Image.new("RGB", size) + pixels = [ + ((x * 7 + y * 11) % 256,) * 3 for y in range(size[1]) for x in range(size[0]) + ] + im.putdata(pixels) + palette = Image.new("P", (1, 1)) + palette.putpalette((0, 0, 0, 255, 255, 255)) + + jjn = im.quantize(palette=palette, dither=Image.Dither.JARVIS_JUDICE_NINKE) + floyd = im.quantize(palette=palette, dither=Image.Dither.FLOYDSTEINBERG) + + assert jjn.mode == "P" + assert set(jjn.getdata()) <= {0, 1} + assert jjn.tobytes() != floyd.tobytes() + + +def test_jjn_hopper_reference_image() -> None: + source = hopper("RGB").crop((24, 24, 56, 56)) + result = source.convert("1", dither=Image.Dither.JARVIS_JUDICE_NINKE) + with Image.open("Tests/images/hopper_jjn.pbm") as expected: + assert_image_equal(result, expected) + + +def test_jjn_empty_image() -> None: + assert Image.new("L", (0, 0)).convert( + "1", dither=Image.Dither.JARVIS_JUDICE_NINKE + ).size == (0, 0) diff --git a/docs/reference/Image.rst b/docs/reference/Image.rst index 86e2d03131b..5223452324a 100644 --- a/docs/reference/Image.rst +++ b/docs/reference/Image.rst @@ -462,6 +462,10 @@ Used to specify the dithering method to use for the Floyd-Steinberg dither + .. py:attribute:: JARVIS_JUDICE_NINKE + + Jarvis-Judice-Ninke dither + Palettes ^^^^^^^^ diff --git a/docs/releasenotes/13.1.0.rst b/docs/releasenotes/13.1.0.rst index af96eda5bec..5031752fcd5 100644 --- a/docs/releasenotes/13.1.0.rst +++ b/docs/releasenotes/13.1.0.rst @@ -42,6 +42,12 @@ TODO API additions ============= +Jarvis-Judice-Ninke dithering +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:data:`PIL.Image.Dither.JARVIS_JUDICE_NINKE` can now be used when +converting images to black and white or to a palette with error diffusion. + TODO ^^^^ diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 241b52b65b1..1dd5da38680 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -171,6 +171,7 @@ class Dither(IntEnum): ORDERED = 1 # Not yet implemented RASTERIZE = 2 # Not yet implemented FLOYDSTEINBERG = 3 # default + JARVIS_JUDICE_NINKE = 4 # palettes/quantizers @@ -1053,8 +1054,9 @@ def convert( should have length 12. :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`, :data:`Dither.FLOYDSTEINBERG` + (default) or :data:`Dither.JARVIS_JUDICE_NINKE`. Note that this 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`. @@ -1315,8 +1317,8 @@ 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`, :data:`Dither.FLOYDSTEINBERG` + (default) or :data:`Dither.JARVIS_JUDICE_NINKE`. :returns: A new image """ diff --git a/src/libImaging/Convert.c b/src/libImaging/Convert.c index 6ff657cf55e..6642e9fd6a1 100644 --- a/src/libImaging/Convert.c +++ b/src/libImaging/Convert.c @@ -33,6 +33,7 @@ */ #include "Imaging.h" +#include /* ITU-R Recommendation 601-2 (assuming nonlinear RGB) */ #define L(rgb) ((INT32)(rgb)[0] * 299 + (INT32)(rgb)[1] * 587 + (INT32)(rgb)[2] * 114) @@ -1179,6 +1180,102 @@ frompalette(Imaging imOut, Imaging imIn, const ModeID mode) { return imOut; } +/* Jarvis-Judice-Ninke error diffusion uses a 5x3 kernel (divisor 48). + * A three-row ring buffer stores weighted error for the current scanline and + * the next two scanlines. Taps outside the image bounds are skipped. */ +static const int jjn_taps[][3] = { + {1, 0, 7}, + {2, 0, 5}, + {-2, 1, 3}, + {-1, 1, 5}, + {0, 1, 7}, + {1, 1, 5}, + {2, 1, 3}, + {-2, 2, 1}, + {-1, 2, 3}, + {0, 2, 5}, + {1, 2, 3}, + {2, 2, 1} +}; + +static Imaging +dither_jjn(Imaging imOut, Imaging imIn, ImagingPalette palette, int alpha) { + ImagingSectionCookie cookie; + int width = imIn->xsize; + int channels = palette ? 3 : 1; + + if (width == 0 || imIn->ysize == 0) { + return imOut; + } + + if ((size_t)width > SIZE_MAX / (3 * sizeof(int) * (size_t)channels)) { + ImagingDelete(imOut); + return ImagingError_MemoryError(); + } + + size_t row_size = (size_t)width * (size_t)channels; + int *errors = calloc(3 * row_size, sizeof(int)); + if (!errors) { + ImagingDelete(imOut); + return ImagingError_MemoryError(); + } + + ImagingSectionEnter(&cookie); + for (int y = 0; y < imIn->ysize; y++) { + const UINT8 *in = (const UINT8 *)imIn->image[y]; + UINT8 *out = alpha ? (UINT8 *)imOut->image32[y] : imOut->image8[y]; + int current_row = y % 3; + + for (int x = 0; x < width; x++) { + int values[3]; + int count = palette ? 3 : 1; + size_t pos = (size_t)current_row * row_size + (size_t)x * channels; + + if (palette) { + for (int c = 0; c < 3; c++) { + int original = imIn->bands == 1 ? in[x] : in[x * 4 + c]; + values[c] = CLIP8(original + errors[pos + c] / 48); + } + INT16 *cache = + &ImagingPaletteCache(palette, values[0], values[1], values[2]); + if (cache[0] == 0x100) { + ImagingPaletteCacheUpdate(palette, values[0], values[1], values[2]); + } + if (alpha) { + out[x * 4] = out[x * 4 + 1] = out[x * 4 + 2] = (UINT8)cache[0]; + out[x * 4 + 3] = 255; + } else { + out[x] = (UINT8)cache[0]; + } + for (int c = 0; c < 3; c++) { + values[c] -= (int)palette->palette[cache[0] * 4 + c]; + } + } else { + int original = imIn->bands == 1 ? in[x] : L(in + x * 4) / 1000; + values[0] = CLIP8(original + errors[pos] / 48); + out[x] = values[0] > 128 ? 255 : 0; + values[0] -= out[x]; + } + + for (size_t i = 0; i < sizeof(jjn_taps) / sizeof(jjn_taps[0]); i++) { + int xx = x + jjn_taps[i][0]; + int yy = y + jjn_taps[i][1]; + if (xx < 0 || xx >= width || yy >= imIn->ysize) { + continue; + } + size_t next = (size_t)(yy % 3) * row_size + (size_t)xx * channels; + for (int c = 0; c < count; c++) { + errors[next + c] += values[c] * jjn_taps[i][2]; + } + } + } + memset(errors + (size_t)current_row * row_size, 0, row_size * sizeof(int)); + } + ImagingSectionLeave(&cookie); + free(errors); + return imOut; +} + #if defined(_MSC_VER) #pragma optimize("", off) #endif @@ -1239,7 +1336,7 @@ topalette( ImagingPaletteDelete(imOut->palette); imOut->palette = ImagingPaletteDuplicate(palette); - if (imIn->bands < 3) { + if (imIn->bands < 3 && dither != IMAGING_DITHER_JARVIS_JUDICE_NINKE) { /* grayscale image */ /* Grayscale palette: copy data as is */ @@ -1279,7 +1376,14 @@ topalette( return NULL; } - if (dither != IMAGING_DITHER_NONE) { + if (dither == IMAGING_DITHER_JARVIS_JUDICE_NINKE) { + if (!dither_jjn(imOut, imIn, palette, alpha)) { + if (inpalette != palette) { + ImagingPaletteDelete(palette); + } + return NULL; + } + } else if (dither != IMAGING_DITHER_NONE) { /* floyd-steinberg dither */ int *errors; @@ -1694,8 +1798,8 @@ static struct { * NULL to use a default palette. * @param dither Dither method when converting to "P", "PA" or "1". * Currently, for historical PIL reasons, - * any value other than IMAGING_DITHER_NONE uses Floyd-Steinberg - * dithering. + * any value other than IMAGING_DITHER_NONE or + * IMAGING_DITHER_JARVIS_JUDICE_NINKE uses Floyd-Steinberg dithering. * @return The resulting Imaging object, * or NULL with a Python exception set on failure. */ @@ -1737,6 +1841,16 @@ ImagingConvert( return topalette(imOut, imIn, mode, palette, dither); } + if (dither == IMAGING_DITHER_JARVIS_JUDICE_NINKE && mode == IMAGING_MODE_1) { + if (imIn->mode != IMAGING_MODE_L && imIn->mode != IMAGING_MODE_RGB) { + return (Imaging)ImagingError_ValueError("conversion not supported"); + } + imOut = ImagingNew2Dirty(IMAGING_MODE_1, imOut, imIn); + if (!imOut) { + return NULL; + } + return dither_jjn(imOut, imIn, NULL, 0); + } if (dither != IMAGING_DITHER_NONE && mode == IMAGING_MODE_1) { return tobilevel(imOut, imIn); } diff --git a/src/libImaging/Imaging.h b/src/libImaging/Imaging.h index 0cb0d5ca80d..89611084918 100644 --- a/src/libImaging/Imaging.h +++ b/src/libImaging/Imaging.h @@ -314,6 +314,7 @@ typedef enum { typedef enum { IMAGING_DITHER_NONE = 0, IMAGING_DITHER_FLOYDSTEINBERG = 3, // Default + IMAGING_DITHER_JARVIS_JUDICE_NINKE = 4, } ImagingDitherType; typedef int (*ImagingTransformMap)(double *X, double *Y, int x, int y, void *data);