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..07a92c6142d --- /dev/null +++ b/Tests/test_image_dither_jjn.py @@ -0,0 +1,100 @@ +"""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 + +# The JJN kernel has twelve forward neighbors and is normalized by 48. +TAPS = ( + (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), +) + + +def _expected_bilevel(values: list[int], size: tuple[int, int]) -> list[int]: + width, height = size + errors = [[0] * width for _ in range(3)] + output: list[int] = [] + + for y in range(height): + for x in range(width): + weighted_error = errors[y % 3][x] + correction = abs(weighted_error) // 48 + if weighted_error < 0: + correction = -correction + value = max(0, min(255, values[y * width + x] + correction)) + color = 255 if value > 128 else 0 + output.append(color) + + for dx, dy, weight in TAPS: + xx, yy = x + dx, y + dy + if 0 <= xx < width and yy < height: + errors[yy % 3][xx] += (value - color) * weight + + errors[y % 3] = [0] * width + + return output + + +@pytest.mark.parametrize("size", [(1, 1), (1, 9), (9, 1), (3, 3), (13, 11)]) +@pytest.mark.parametrize("mode", ["L", "RGB"]) +def test_jjn_bilevel_matches_diffusion_kernel(mode: str, size: tuple[int, int]) -> None: + width, height = size + values = [(x * 37 + y * 29 + 65) % 256 for y in range(height) for x in range(width)] + if mode == "RGB": + im = Image.new("RGB", size) + im.putdata([(v, v, v) for v in values]) + else: + im = Image.new("L", size) + im.putdata(values) + + result = im.convert("1", dither=Image.Dither.JARVIS_JUDICE_NINKE) + + assert result.mode == "1" + assert list(result.getdata()) == _expected_bilevel(values, size) + + +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: + # Compare with a checked-in reference image, not the Python diffusion model. + 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 140bcbe3115..673ca7c01b1 100644 --- a/docs/releasenotes/13.1.0.rst +++ b/docs/releasenotes/13.1.0.rst @@ -41,6 +41,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 cefe6f1fc96..95422eeb68a 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 @@ -1054,8 +1055,9 @@ def convert( should be 4- or 12-sequence containing floating point values. :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`. @@ -1299,8 +1301,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 ce41535ec22..1e665fe55c8 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) @@ -1116,6 +1117,105 @@ frompalette(Imaging imOut, Imaging imIn, const ModeID mode) { return imOut; } +/* Image.Dither.JARVIS_JUDICE_NINKE has the numeric value 4. */ +#define DITHER_JARVIS_JUDICE_NINKE 4 + +/* 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 @@ -1172,7 +1272,7 @@ topalette( ImagingPaletteDelete(imOut->palette); imOut->palette = ImagingPaletteDuplicate(palette); - if (imIn->bands == 1) { + if (imIn->bands == 1 && dither != DITHER_JARVIS_JUDICE_NINKE) { /* grayscale image */ /* Grayscale palette: copy data as is */ @@ -1198,7 +1298,14 @@ topalette( return NULL; } - if (dither) { + if (dither == DITHER_JARVIS_JUDICE_NINKE) { + if (!dither_jjn(imOut, imIn, palette, alpha)) { + if (inpalette != palette) { + ImagingPaletteDelete(palette); + } + return NULL; + } + } else if (dither) { /* floyd-steinberg dither */ int *errors; @@ -1578,7 +1685,7 @@ static struct { * @param mode Target mode. * @param palette Target palette for conversions to "P" or "PA"; * NULL to use a default palette. - * @param dither Nonzero to dither when converting to "P", "PA" or "1". + * @param dither Dithering choice (0: none, 3: Floyd-Steinberg, 4: JJN). * @return The resulting Imaging object, * or NULL with a Python exception set on failure. */ @@ -1616,6 +1723,16 @@ ImagingConvert( return topalette(imOut, imIn, mode, palette, dither); } + if (dither == 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 && mode == IMAGING_MODE_1) { return tobilevel(imOut, imIn); }