Skip to content
35 changes: 35 additions & 0 deletions Tests/images/hopper_jjn.pbm
Original file line number Diff line number Diff line change
@@ -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
100 changes: 100 additions & 0 deletions Tests/test_image_dither_jjn.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +11 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this just a reimplementation of JJN in Python? I think house style would be to add a JJN-dithered Hopper test fixture image to compare against?



@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)
4 changes: 4 additions & 0 deletions docs/reference/Image.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
^^^^^^^^

Expand Down
6 changes: 6 additions & 0 deletions docs/releasenotes/13.1.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
^^^^

Expand Down
10 changes: 6 additions & 4 deletions src/PIL/Image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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
"""

Expand Down
123 changes: 120 additions & 3 deletions src/libImaging/Convert.c
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
*/

#include "Imaging.h"
#include <stdint.h>

/* ITU-R Recommendation 601-2 (assuming nonlinear RGB) */
#define L(rgb) ((INT32)(rgb)[0] * 299 + (INT32)(rgb)[1] * 587 + (INT32)(rgb)[2] * 114)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 */
Expand All @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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);
}
Expand Down