From 6216187fb67fea90fb95247c045a2cf737062043 Mon Sep 17 00:00:00 2001 From: Amar Al-Zubaidi Date: Mon, 25 Aug 2025 01:07:31 -0400 Subject: [PATCH] Add the magic kernel sharp 2021 resampling filter --- Tests/test_image_resample.py | 39 +++++++++++++++++++++++++++++++ Tests/test_image_resize.py | 3 +++ docs/handbook/concepts.rst | 45 +++++++++++++++++++++++------------- src/PIL/Image.py | 25 ++++++++++++-------- src/libImaging/Imaging.h | 1 + src/libImaging/Resample.c | 25 ++++++++++++++++++++ 6 files changed, 113 insertions(+), 25 deletions(-) diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index 73b25ed51..c891ae34e 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -165,6 +165,21 @@ class TestImagingCoreResampleAccuracy: 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) @@ -226,6 +241,23 @@ class TestImagingCoreResampleAccuracy: 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 @@ -309,6 +341,7 @@ class TestCoreResampleAlphaCorrect: 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: @@ -318,6 +351,7 @@ class TestCoreResampleAlphaCorrect: 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, ...] @@ -360,6 +394,9 @@ class TestCoreResampleAlphaCorrect: 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)) @@ -368,6 +405,7 @@ class TestCoreResampleAlphaCorrect: 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: @@ -453,6 +491,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: diff --git a/Tests/test_image_resize.py b/Tests/test_image_resize.py index 270500a44..f5a574cea 100644 --- a/Tests/test_image_resize.py +++ b/Tests/test_image_resize.py @@ -72,6 +72,7 @@ class TestImagingCoreResize: Image.Resampling.HAMMING, Image.Resampling.BICUBIC, Image.Resampling.LANCZOS, + Image.Resampling.MAGIC_KERNEL_SHARP_2021, ), ) def test_reduce_filters(self, resample: Image.Resampling) -> None: @@ -88,6 +89,7 @@ class TestImagingCoreResize: Image.Resampling.HAMMING, Image.Resampling.BICUBIC, Image.Resampling.LANCZOS, + Image.Resampling.MAGIC_KERNEL_SHARP_2021, ), ) def test_enlarge_filters(self, resample: Image.Resampling) -> None: @@ -104,6 +106,7 @@ class TestImagingCoreResize: Image.Resampling.HAMMING, Image.Resampling.BICUBIC, Image.Resampling.LANCZOS, + Image.Resampling.MAGIC_KERNEL_SHARP_2021, ), ) @pytest.mark.parametrize( diff --git a/docs/handbook/concepts.rst b/docs/handbook/concepts.rst index 46f612be3..70a4a04e9 100644 --- a/docs/handbook/concepts.rst +++ b/docs/handbook/concepts.rst @@ -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` | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | ++-------------------------------------------+-------------+------------+-------------+ diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 683c80762..23a7b8b0f 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -151,6 +151,7 @@ class Resampling(IntEnum): HAMMING = 5 BICUBIC = 3 LANCZOS = 1 + MAGIC_KERNEL_SHARP_2021 = 6 _filters_support = { @@ -159,6 +160,7 @@ _filters_support = { Resampling.HAMMING: 1.0, Resampling.BICUBIC: 2.0, Resampling.LANCZOS: 3.0, + Resampling.MAGIC_KERNEL_SHARP_2021: 4.5, } @@ -2215,10 +2217,11 @@ class Image: :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. @@ -2245,6 +2248,7 @@ class Image: Resampling.BILINEAR, Resampling.BICUBIC, Resampling.LANCZOS, + Resampling.MAGIC_KERNEL_SHARP_2021, Resampling.BOX, Resampling.HAMMING, ): @@ -2255,6 +2259,7 @@ class Image: 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"), @@ -2710,10 +2715,11 @@ class Image: :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 @@ -2924,11 +2930,12 @@ class Image: 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: diff --git a/src/libImaging/Imaging.h b/src/libImaging/Imaging.h index bfe67d462..16efd077f 100644 --- a/src/libImaging/Imaging.h +++ b/src/libImaging/Imaging.h @@ -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); diff --git a/src/libImaging/Resample.c b/src/libImaging/Resample.c index b114e0023..43b3f2088 100644 --- a/src/libImaging/Resample.c +++ b/src/libImaging/Resample.c @@ -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, @@ -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"); }