From 3a0eb6b17d12061b7535fb6b587ecb8f19c97523 Mon Sep 17 00:00:00 2001 From: hiyouwhatsapp <89997383+hiyouwhatsapp@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:09:30 +0300 Subject: [PATCH 01/10] Add Jarvis-Judice-Ninke dithering for bilevel and palette images --- Tests/test_image_dither_jjn.py | 83 +++++++++++++++++++++++++ docs/releasenotes/13.1.0.rst | 6 ++ src/PIL/Image.py | 9 +-- src/libImaging/Convert.c | 110 ++++++++++++++++++++++++++++++++- 4 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 Tests/test_image_dither_jjn.py diff --git a/Tests/test_image_dither_jjn.py b/Tests/test_image_dither_jjn.py new file mode 100644 index 00000000000..07980a518ca --- /dev/null +++ b/Tests/test_image_dither_jjn.py @@ -0,0 +1,83 @@ +"""Jarvis-Judice-Ninke error diffusion for bilevel and palette images.""" + +from __future__ import annotations + +import pytest + +from PIL import Image + +# 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 = ( + weighted_error // 48 if weighted_error >= 0 else -(-weighted_error // 48) + ) + 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) + im.putdata( + [(v, v, v) for y in range(size[1]) for x in range(size[0]) + for v in [(x * 7 + y * 11) % 256]] + ) + 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_empty_image() -> None: + assert Image.new("L", (0, 0)).convert( + "1", dither=Image.Dither.JARVIS_JUDICE_NINKE + ).size == (0, 0) diff --git a/docs/releasenotes/13.1.0.rst b/docs/releasenotes/13.1.0.rst index 140bcbe3115..31d7b7f7c99 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..f6cf1fc80bf 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,8 @@ 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 +1300,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..1ae14535e36 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,92 @@ frompalette(Imaging imOut, Imaging imIn, const ModeID mode) { return imOut; } + +/* Jarvis-Judice-Ninke error diffusion, with a 5x3 kernel (divisor 48). + * Keep three rows of weighted errors; every contribution stays in bounds. */ +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 +1259,7 @@ topalette( ImagingPaletteDelete(imOut->palette); imOut->palette = ImagingPaletteDuplicate(palette); - if (imIn->bands == 1) { + if (imIn->bands == 1 && dither != 4) { /* grayscale image */ /* Grayscale palette: copy data as is */ @@ -1198,7 +1285,14 @@ topalette( return NULL; } - if (dither) { + if (dither == 4) { + if (!dither_jjn(imOut, imIn, palette, alpha)) { + if (inpalette != palette) { + ImagingPaletteDelete(palette); + } + return NULL; + } + } else if (dither) { /* floyd-steinberg dither */ int *errors; @@ -1578,7 +1672,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 +1710,16 @@ ImagingConvert( return topalette(imOut, imIn, mode, palette, dither); } + if (dither == 4 && 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); } From 147af73af539c2fbef83aa5bc35248fe9e93eade Mon Sep 17 00:00:00 2001 From: hiyouwhatsapp <89997383+hiyouwhatsapp@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:12:02 +0300 Subject: [PATCH 02/10] Tidy JJN test fixtures --- Tests/test_image_dither_jjn.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Tests/test_image_dither_jjn.py b/Tests/test_image_dither_jjn.py index 07980a518ca..6566e74bb6f 100644 --- a/Tests/test_image_dither_jjn.py +++ b/Tests/test_image_dither_jjn.py @@ -45,7 +45,11 @@ 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)] + 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]) @@ -62,10 +66,12 @@ def test_jjn_bilevel_matches_diffusion_kernel( def test_jjn_palette_differs_from_floyd_steinberg() -> None: size = (33, 15) im = Image.new("RGB", size) - im.putdata( - [(v, v, v) for y in range(size[1]) for x in range(size[0]) - for v in [(x * 7 + y * 11) % 256]] - ) + 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)) From cfd926a8d156e386b96ac5fbd033d271304e006b Mon Sep 17 00:00:00 2001 From: hiyouwhatsapp <89997383+hiyouwhatsapp@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:12:21 +0300 Subject: [PATCH 03/10] Keep reference diffuser formatting consistent --- Tests/test_image_dither_jjn.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/test_image_dither_jjn.py b/Tests/test_image_dither_jjn.py index 6566e74bb6f..477edfd9b78 100644 --- a/Tests/test_image_dither_jjn.py +++ b/Tests/test_image_dither_jjn.py @@ -22,9 +22,9 @@ def _expected_bilevel(values: list[int], size: tuple[int, int]) -> list[int]: for y in range(height): for x in range(width): weighted_error = errors[y % 3][x] - correction = ( - weighted_error // 48 if weighted_error >= 0 else -(-weighted_error // 48) - ) + 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) From 840ec172f497e4344188a3604604d23d84ea3048 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:22:34 +0000 Subject: [PATCH 04/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- Tests/test_image_dither_jjn.py | 29 +++++++++++++++-------------- src/libImaging/Convert.c | 19 ++++++++++++++----- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/Tests/test_image_dither_jjn.py b/Tests/test_image_dither_jjn.py index 477edfd9b78..726f911f742 100644 --- a/Tests/test_image_dither_jjn.py +++ b/Tests/test_image_dither_jjn.py @@ -8,9 +8,18 @@ # 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), + (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), ) @@ -41,15 +50,9 @@ def _expected_bilevel(values: list[int], size: tuple[int, int]) -> list[int]: @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: +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) - ] + 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]) @@ -67,9 +70,7 @@ 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]) + ((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)) diff --git a/src/libImaging/Convert.c b/src/libImaging/Convert.c index 1ae14535e36..7f0b7a810fb 100644 --- a/src/libImaging/Convert.c +++ b/src/libImaging/Convert.c @@ -1117,13 +1117,21 @@ frompalette(Imaging imOut, Imaging imIn, const ModeID mode) { return imOut; } - /* Jarvis-Judice-Ninke error diffusion, with a 5x3 kernel (divisor 48). * Keep three rows of weighted errors; every contribution stays in bounds. */ 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} + {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 @@ -1164,7 +1172,8 @@ dither_jjn(Imaging imOut, Imaging imIn, ImagingPalette palette, int alpha) { 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]); + INT16 *cache = + &ImagingPaletteCache(palette, values[0], values[1], values[2]); if (cache[0] == 0x100) { ImagingPaletteCacheUpdate(palette, values[0], values[1], values[2]); } From 99b2a0680fc9f31f5d3d9cb5ccac2650a40a5ab7 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 21 Oct 2026 14:46:08 +1100 Subject: [PATCH 05/10] Fix line length --- src/PIL/Image.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index f6cf1fc80bf..95422eeb68a 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1056,7 +1056,8 @@ def convert( :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`, :data:`Dither.FLOYDSTEINBERG` - (default) or :data:`Dither.JARVIS_JUDICE_NINKE`. Note that this is not used when ``matrix`` is supplied. + (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`. From 10160d2650040ddf36916276bd6ab57345f30dd6 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 21 Oct 2026 14:53:45 +1100 Subject: [PATCH 06/10] Document new dither mode --- docs/reference/Image.rst | 4 ++++ 1 file changed, 4 insertions(+) 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 ^^^^^^^^ From af8ae3486f7f19bc32e387c5c05d298f9625b4c0 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 21 Oct 2026 15:01:09 +1100 Subject: [PATCH 07/10] Fix underline length --- docs/releasenotes/13.1.0.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releasenotes/13.1.0.rst b/docs/releasenotes/13.1.0.rst index 31d7b7f7c99..673ca7c01b1 100644 --- a/docs/releasenotes/13.1.0.rst +++ b/docs/releasenotes/13.1.0.rst @@ -42,7 +42,7 @@ 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. From 59370d3d9c2397f4852876b928013a99482300b0 Mon Sep 17 00:00:00 2001 From: hiyouwhatsapp <89997383+hiyouwhatsapp@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:45:43 +0200 Subject: [PATCH 08/10] fix: name JJN dither mode and clarify error-buffer bounds --- src/libImaging/Convert.c | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/libImaging/Convert.c b/src/libImaging/Convert.c index 7f0b7a810fb..1e665fe55c8 100644 --- a/src/libImaging/Convert.c +++ b/src/libImaging/Convert.c @@ -1117,8 +1117,12 @@ frompalette(Imaging imOut, Imaging imIn, const ModeID mode) { return imOut; } -/* Jarvis-Judice-Ninke error diffusion, with a 5x3 kernel (divisor 48). - * Keep three rows of weighted errors; every contribution stays in bounds. */ +/* 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}, @@ -1268,7 +1272,7 @@ topalette( ImagingPaletteDelete(imOut->palette); imOut->palette = ImagingPaletteDuplicate(palette); - if (imIn->bands == 1 && dither != 4) { + if (imIn->bands == 1 && dither != DITHER_JARVIS_JUDICE_NINKE) { /* grayscale image */ /* Grayscale palette: copy data as is */ @@ -1294,7 +1298,7 @@ topalette( return NULL; } - if (dither == 4) { + if (dither == DITHER_JARVIS_JUDICE_NINKE) { if (!dither_jjn(imOut, imIn, palette, alpha)) { if (inpalette != palette) { ImagingPaletteDelete(palette); @@ -1719,7 +1723,7 @@ ImagingConvert( return topalette(imOut, imIn, mode, palette, dither); } - if (dither == 4 && mode == IMAGING_MODE_1) { + 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"); } From 02da160243950379ec2ffca47fb180d503310a0e Mon Sep 17 00:00:00 2001 From: hiyouwhatsapp <89997383+hiyouwhatsapp@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:48:30 +0200 Subject: [PATCH 09/10] test: add Hopper JJN reference image --- Tests/images/hopper_jjn.pbm | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 Tests/images/hopper_jjn.pbm 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 From 1825b52fb8b4c455a0b061717a9d9feb078fe3d5 Mon Sep 17 00:00:00 2001 From: hiyouwhatsapp <89997383+hiyouwhatsapp@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:48:42 +0200 Subject: [PATCH 10/10] test: compare JJN Hopper output to reference image --- Tests/test_image_dither_jjn.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Tests/test_image_dither_jjn.py b/Tests/test_image_dither_jjn.py index 726f911f742..07a92c6142d 100644 --- a/Tests/test_image_dither_jjn.py +++ b/Tests/test_image_dither_jjn.py @@ -6,6 +6,8 @@ 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), @@ -84,6 +86,14 @@ def test_jjn_palette_differs_from_floyd_steinberg() -> None: 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