Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
49 changes: 49 additions & 0 deletions Tests/test_image_dither_jjn.py
Original file line number Diff line number Diff line change
@@ -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)
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 @@ -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
^^^^

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 @@ -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`.
Expand Down Expand Up @@ -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
"""

Expand Down
122 changes: 118 additions & 4 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 @@ -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
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions src/libImaging/Imaging.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down