Skip to content
Closed
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
45 changes: 45 additions & 0 deletions Tests/test_image_resample.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,20 @@ def test_reduce_lanczos(self, mode: str) -> None:
for channel in case.split():
self.check_case(channel, self.make_sample(data, (8, 8)))

@pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L"))
def test_reduce_magic_kernel_sharp_2021(self, mode: str) -> None:
case = self.make_case(mode, (20, 20), 0xE1)
case = case.resize((10, 10), Image.Resampling.MAGIC_KERNEL_SHARP_2021)
# fmt: off
data = ("e1 e1 e1 e3 d7"
"e1 e1 e1 e3 d7"
"e1 e1 e1 e3 d7"
"e3 e3 e3 e5 d9"
"d7 d7 d7 d9 ce")
# fmt: on
for channel in case.split():
self.check_case(channel, self.make_sample(data, (10, 10)))

@pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L"))
def test_enlarge_box(self, mode: str) -> None:
case = self.make_case(mode, (2, 2), 0xE1)
Expand Down Expand Up @@ -226,6 +240,23 @@ def test_enlarge_lanczos(self, mode: str) -> None:
for channel in case.split():
self.check_case(channel, self.make_sample(data, (12, 12)))

@pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L"))
def test_enlarge_magic_kernel_sharp_2021(self, mode: str) -> None:
case = self.make_case(mode, (8, 8), 0xE1)
case = case.resize((16, 16), Image.Resampling.MAGIC_KERNEL_SHARP_2021)
# fmt: off
data = ("e1 e1 e2 e0 de e8 f4 ba"
"e1 e1 e2 e0 de e8 f4 ba"
"e2 e2 e3 e1 df e9 f5 ba"
"e0 e0 e1 df dd e7 f3 b9"
"de de df dd db e5 f0 b8"
"e8 e8 e9 e7 e5 ef fc be"
"f4 f4 f5 f2 f0 fc ff c5"
"ba ba bb ba b9 bf c6 a3")
# fmt: on
for channel in case.split():
self.check_case(channel, self.make_sample(data, (16, 16)))

def test_box_filter_correct_range(self) -> None:
im = Image.new("RGB", (8, 8), "#1688ff").resize(
(100, 100), Image.Resampling.BOX
Expand Down Expand Up @@ -309,6 +340,9 @@ def test_levels_rgba(self) -> None:
self.run_levels_case(case.resize((512, 32), Image.Resampling.HAMMING))
self.run_levels_case(case.resize((512, 32), Image.Resampling.BICUBIC))
self.run_levels_case(case.resize((512, 32), Image.Resampling.LANCZOS))
self.run_levels_case(
case.resize((512, 32), Image.Resampling.MAGIC_KERNEL_SHARP_2021)
)

@pytest.mark.xfail(reason="Current implementation isn't precise enough")
def test_levels_la(self) -> None:
Expand All @@ -318,6 +352,9 @@ def test_levels_la(self) -> None:
self.run_levels_case(case.resize((512, 32), Image.Resampling.HAMMING))
self.run_levels_case(case.resize((512, 32), Image.Resampling.BICUBIC))
self.run_levels_case(case.resize((512, 32), Image.Resampling.LANCZOS))
self.run_levels_case(
case.resize((512, 32), Image.Resampling.MAGIC_KERNEL_SHARP_2021)
)

def make_dirty_case(
self, mode: str, clean_pixel: tuple[int, ...], dirty_pixel: tuple[int, ...]
Expand Down Expand Up @@ -360,6 +397,10 @@ def test_dirty_pixels_rgba(self) -> None:
self.run_dirty_case(
case.resize((20, 20), Image.Resampling.LANCZOS), (255, 255, 0)
)
self.run_dirty_case(
case.resize((20, 20), Image.Resampling.MAGIC_KERNEL_SHARP_2021),
(255, 255, 0),
)

def test_dirty_pixels_la(self) -> None:
case = self.make_dirty_case("LA", (255, 128), (0, 0))
Expand All @@ -368,6 +409,9 @@ def test_dirty_pixels_la(self) -> None:
self.run_dirty_case(case.resize((20, 20), Image.Resampling.HAMMING), (255,))
self.run_dirty_case(case.resize((20, 20), Image.Resampling.BICUBIC), (255,))
self.run_dirty_case(case.resize((20, 20), Image.Resampling.LANCZOS), (255,))
self.run_dirty_case(
case.resize((20, 20), Image.Resampling.MAGIC_KERNEL_SHARP_2021), (255,)
)


class TestCoreResamplePasses:
Expand Down Expand Up @@ -453,6 +497,7 @@ class TestCoreResampleBox:
Image.Resampling.HAMMING,
Image.Resampling.BICUBIC,
Image.Resampling.LANCZOS,
Image.Resampling.MAGIC_KERNEL_SHARP_2021,
),
)
def test_wrong_arguments(self, resample: Image.Resampling) -> None:
Expand Down
3 changes: 3 additions & 0 deletions Tests/test_image_resize.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def test_convolution_modes(self) -> None:
Image.Resampling.HAMMING,
Image.Resampling.BICUBIC,
Image.Resampling.LANCZOS,
Image.Resampling.MAGIC_KERNEL_SHARP_2021,
),
)
def test_reduce_filters(self, resample: Image.Resampling) -> None:
Expand All @@ -88,6 +89,7 @@ def test_reduce_filters(self, resample: Image.Resampling) -> None:
Image.Resampling.HAMMING,
Image.Resampling.BICUBIC,
Image.Resampling.LANCZOS,
Image.Resampling.MAGIC_KERNEL_SHARP_2021,
),
)
def test_enlarge_filters(self, resample: Image.Resampling) -> None:
Expand All @@ -104,6 +106,7 @@ def test_enlarge_filters(self, resample: Image.Resampling) -> None:
Image.Resampling.HAMMING,
Image.Resampling.BICUBIC,
Image.Resampling.LANCZOS,
Image.Resampling.MAGIC_KERNEL_SHARP_2021,
),
)
@pytest.mark.parametrize(
Expand Down
45 changes: 29 additions & 16 deletions docs/handbook/concepts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -217,23 +217,36 @@ pixel, the Python Imaging Library provides different resampling *filters*.

.. versionadded:: 1.1.3

.. data:: Resampling.MAGIC_KERNEL_SHARP_2021
:noindex:

A high-quality sharpening filter designed by John Costella, known as the
'Magic Kernel'. It is engineered to produce sharp results with minimal
resampling artifacts like ringing and aliasing.
This filter can only be used with the :py:meth:`~PIL.Image.Image.resize`
and :py:meth:`~PIL.Image.Image.thumbnail` methods.

.. versionadded:: 11.4.0


Filters comparison table
~~~~~~~~~~~~~~~~~~~~~~~~

+---------------------------+-------------+-----------+-------------+
| Filter | Downscaling | Upscaling | Performance |
| | quality | quality | |
+===========================+=============+===========+=============+
|:data:`Resampling.NEAREST` | | | ⭐⭐⭐⭐⭐ |
+---------------------------+-------------+-----------+-------------+
|:data:`Resampling.BOX` | ⭐ | | ⭐⭐⭐⭐ |
+---------------------------+-------------+-----------+-------------+
|:data:`Resampling.BILINEAR`| ⭐ | ⭐ | ⭐⭐⭐ |
+---------------------------+-------------+-----------+-------------+
|:data:`Resampling.HAMMING` | ⭐⭐ | | ⭐⭐⭐ |
+---------------------------+-------------+-----------+-------------+
|:data:`Resampling.BICUBIC` | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
+---------------------------+-------------+-----------+-------------+
|:data:`Resampling.LANCZOS` | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐ |
+---------------------------+-------------+-----------+-------------+
+-------------------------------------------+-------------+------------+-------------+
| Filter | Downscaling | Upscaling | Performance |
| | quality | quality | |
+===========================================+=============+============+=============+
|:data:`Resampling.NEAREST` | | | ⭐⭐⭐⭐⭐ |
+-------------------------------------------+-------------+------------+-------------+
|:data:`Resampling.BOX` | ⭐ | | ⭐⭐⭐⭐ |
+-------------------------------------------+-------------+------------+-------------+
|:data:`Resampling.BILINEAR` | ⭐ | ⭐ | ⭐⭐⭐ |
+-------------------------------------------+-------------+------------+-------------+
|:data:`Resampling.HAMMING` | ⭐⭐ | | ⭐⭐⭐ |
+-------------------------------------------+-------------+------------+-------------+
|:data:`Resampling.BICUBIC` | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
+-------------------------------------------+-------------+------------+-------------+
|:data:`Resampling.LANCZOS` | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐ |
+-------------------------------------------+-------------+------------+-------------+
|:data:`Resampling.MAGIC_KERNEL_SHARP_2021` | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ |
+-------------------------------------------+-------------+------------+-------------+
33 changes: 24 additions & 9 deletions src/PIL/Image.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ class Resampling(IntEnum):
HAMMING = 5
BICUBIC = 3
LANCZOS = 1
MAGIC_KERNEL_SHARP_2021 = 6


_filters_support = {
Expand All @@ -159,6 +160,7 @@ class Resampling(IntEnum):
Resampling.HAMMING: 1.0,
Resampling.BICUBIC: 2.0,
Resampling.LANCZOS: 3.0,
Resampling.MAGIC_KERNEL_SHARP_2021: 4.5,
}


Expand Down Expand Up @@ -2215,10 +2217,11 @@ def resize(
:param resample: An optional resampling filter. This can be
one of :py:data:`Resampling.NEAREST`, :py:data:`Resampling.BOX`,
:py:data:`Resampling.BILINEAR`, :py:data:`Resampling.HAMMING`,
:py:data:`Resampling.BICUBIC` or :py:data:`Resampling.LANCZOS`.
If the image has mode "1" or "P", it is always set to
:py:data:`Resampling.NEAREST`. Otherwise, the default filter is
:py:data:`Resampling.BICUBIC`. See: :ref:`concept-filters`.
:py:data:`Resampling.BICUBIC`, :py:data:`Resampling.LANCZOS` or
:py:data:`Resampling.MAGIC_KERNEL_SHARP_2021`. If the image has mode
"1" or "P", it is always set to :py:data:`Resampling.NEAREST`.
Otherwise, the default filter is :py:data:`Resampling.BICUBIC`. See:
:ref:`concept-filters`.
:param box: An optional 4-tuple of floats providing
the source image region to be scaled.
The values must be within (0, 0, width, height) rectangle.
Expand All @@ -2245,6 +2248,7 @@ def resize(
Resampling.BILINEAR,
Resampling.BICUBIC,
Resampling.LANCZOS,
Resampling.MAGIC_KERNEL_SHARP_2021,
Resampling.BOX,
Resampling.HAMMING,
):
Expand All @@ -2255,6 +2259,10 @@ def resize(
for filter in (
(Resampling.NEAREST, "Image.Resampling.NEAREST"),
(Resampling.LANCZOS, "Image.Resampling.LANCZOS"),
(
Resampling.MAGIC_KERNEL_SHARP_2021,
"Image.Resampling.MAGIC_KERNEL_SHARP_2021",
),
(Resampling.BILINEAR, "Image.Resampling.BILINEAR"),
(Resampling.BICUBIC, "Image.Resampling.BICUBIC"),
(Resampling.BOX, "Image.Resampling.BOX"),
Expand Down Expand Up @@ -2710,10 +2718,11 @@ def thumbnail(
:param resample: Optional resampling filter. This can be one
of :py:data:`Resampling.NEAREST`, :py:data:`Resampling.BOX`,
:py:data:`Resampling.BILINEAR`, :py:data:`Resampling.HAMMING`,
:py:data:`Resampling.BICUBIC` or :py:data:`Resampling.LANCZOS`.
If omitted, it defaults to :py:data:`Resampling.BICUBIC`.
(was :py:data:`Resampling.NEAREST` prior to version 2.5.0).
See: :ref:`concept-filters`.
:py:data:`Resampling.BICUBIC`, :py:data:`Resampling.LANCZOS` or
:py:data:`Resampling.MAGIC_KERNEL_SHARP_2021`. If omitted, it
defaults to :py:data:`Resampling.BICUBIC`. (was
:py:data:`Resampling.NEAREST` prior to version 2.5.0). See:
:ref:`concept-filters`.
:param reducing_gap: Apply optimization by resizing the image
in two steps. First, reducing the image by integer times
using :py:meth:`~PIL.Image.Image.reduce` or
Expand Down Expand Up @@ -2924,11 +2933,17 @@ def __transformer(
Resampling.BILINEAR,
Resampling.BICUBIC,
):
if resample in (Resampling.BOX, Resampling.HAMMING, Resampling.LANCZOS):
if resample in (
Resampling.BOX,
Resampling.HAMMING,
Resampling.LANCZOS,
Resampling.MAGIC_KERNEL_SHARP_2021,
):
unusable: dict[int, str] = {
Resampling.BOX: "Image.Resampling.BOX",
Resampling.HAMMING: "Image.Resampling.HAMMING",
Resampling.LANCZOS: "Image.Resampling.LANCZOS",
Resampling.MAGIC_KERNEL_SHARP_2021: "Image.Resampling.MAGIC_KERNEL_SHARP_2021",
}
msg = unusable[resample] + f" ({resample}) cannot be used."
else:
Expand Down
1 change: 1 addition & 0 deletions src/libImaging/Imaging.h
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ ImagingError_ValueError(const char *message);
#define IMAGING_TRANSFORM_HAMMING 5
#define IMAGING_TRANSFORM_BICUBIC 3
#define IMAGING_TRANSFORM_LANCZOS 1
#define IMAGING_TRANSFORM_MAGIC_KERNEL_SHARP_2021 6

typedef int (*ImagingTransformMap)(double *X, double *Y, int x, int y, void *data);
typedef int (*ImagingTransformFilter)(void *out, Imaging im, double x, double y);
Expand Down
25 changes: 25 additions & 0 deletions src/libImaging/Resample.c
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,33 @@ lanczos_filter(double x) {
return 0.0;
}

static inline double
magic_kernel_sharp_2021_filter(double x) {
x = fabs(x);
if (x < 0.5) {
return 577.0 / 576.0 - 239.0 / 144.0 * x * x;
}
if (x < 1.5) {
return 35.0 / 36.0 * (x - 1.0) * (x - 239.0 / 140.0);
}
if (x < 2.5) {
return 1.0 / 6.0 * (x - 2.0) * (65.0 / 24.0 - x);
}
if (x < 3.5) {
return 1.0 / 36.0 * (x - 3.0) * (x - 3.75);
}
if (x < 4.5) {
return -1.0 / 288.0 * (x - 4.5) * (x - 4.5);
}
return 0.0;
}

static struct filter BOX = {box_filter, 0.5};
static struct filter BILINEAR = {bilinear_filter, 1.0};
static struct filter HAMMING = {hamming_filter, 1.0};
static struct filter BICUBIC = {bicubic_filter, 2.0};
static struct filter LANCZOS = {lanczos_filter, 3.0};
static struct filter MAGIC_KERNEL_SHARP_2021 = {magic_kernel_sharp_2021_filter, 4.5};

/* 8 bits for result. Filter can have negative areas.
In one cases the sum of the coefficients will be negative,
Expand Down Expand Up @@ -695,6 +717,9 @@ ImagingResample(Imaging imIn, int xsize, int ysize, int filter, float box[4]) {
case IMAGING_TRANSFORM_LANCZOS:
filterp = &LANCZOS;
break;
case IMAGING_TRANSFORM_MAGIC_KERNEL_SHARP_2021:
filterp = &MAGIC_KERNEL_SHARP_2021;
break;
default:
return (Imaging)ImagingError_ValueError("unsupported resampling filter");
}
Expand Down
Loading