Add the magic kernel sharp 2021 resampling filter

This commit is contained in:
Amar Al-Zubaidi 2025-08-25 01:07:31 -04:00
parent 97a4d1f593
commit 6216187fb6
6 changed files with 113 additions and 25 deletions

View File

@ -165,6 +165,21 @@ class TestImagingCoreResampleAccuracy:
for channel in case.split(): for channel in case.split():
self.check_case(channel, self.make_sample(data, (8, 8))) 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")) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L"))
def test_enlarge_box(self, mode: str) -> None: def test_enlarge_box(self, mode: str) -> None:
case = self.make_case(mode, (2, 2), 0xE1) case = self.make_case(mode, (2, 2), 0xE1)
@ -226,6 +241,23 @@ class TestImagingCoreResampleAccuracy:
for channel in case.split(): for channel in case.split():
self.check_case(channel, self.make_sample(data, (12, 12))) 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: def test_box_filter_correct_range(self) -> None:
im = Image.new("RGB", (8, 8), "#1688ff").resize( im = Image.new("RGB", (8, 8), "#1688ff").resize(
(100, 100), Image.Resampling.BOX (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.HAMMING))
self.run_levels_case(case.resize((512, 32), Image.Resampling.BICUBIC)) 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.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") @pytest.mark.xfail(reason="Current implementation isn't precise enough")
def test_levels_la(self) -> None: 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.HAMMING))
self.run_levels_case(case.resize((512, 32), Image.Resampling.BICUBIC)) 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.LANCZOS))
self.run_levels_case(case.resize((512, 32), Image.Resampling.MAGIC_KERNEL_SHARP_2021))
def make_dirty_case( def make_dirty_case(
self, mode: str, clean_pixel: tuple[int, ...], dirty_pixel: tuple[int, ...] self, mode: str, clean_pixel: tuple[int, ...], dirty_pixel: tuple[int, ...]
@ -360,6 +394,9 @@ class TestCoreResampleAlphaCorrect:
self.run_dirty_case( self.run_dirty_case(
case.resize((20, 20), Image.Resampling.LANCZOS), (255, 255, 0) 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: def test_dirty_pixels_la(self) -> None:
case = self.make_dirty_case("LA", (255, 128), (0, 0)) 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.HAMMING), (255,))
self.run_dirty_case(case.resize((20, 20), Image.Resampling.BICUBIC), (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.LANCZOS), (255,))
self.run_dirty_case(case.resize((20, 20), Image.Resampling.MAGIC_KERNEL_SHARP_2021), (255,))
class TestCoreResamplePasses: class TestCoreResamplePasses:
@ -453,6 +491,7 @@ class TestCoreResampleBox:
Image.Resampling.HAMMING, Image.Resampling.HAMMING,
Image.Resampling.BICUBIC, Image.Resampling.BICUBIC,
Image.Resampling.LANCZOS, Image.Resampling.LANCZOS,
Image.Resampling.MAGIC_KERNEL_SHARP_2021,
), ),
) )
def test_wrong_arguments(self, resample: Image.Resampling) -> None: def test_wrong_arguments(self, resample: Image.Resampling) -> None:

View File

@ -72,6 +72,7 @@ class TestImagingCoreResize:
Image.Resampling.HAMMING, Image.Resampling.HAMMING,
Image.Resampling.BICUBIC, Image.Resampling.BICUBIC,
Image.Resampling.LANCZOS, Image.Resampling.LANCZOS,
Image.Resampling.MAGIC_KERNEL_SHARP_2021,
), ),
) )
def test_reduce_filters(self, resample: Image.Resampling) -> None: def test_reduce_filters(self, resample: Image.Resampling) -> None:
@ -88,6 +89,7 @@ class TestImagingCoreResize:
Image.Resampling.HAMMING, Image.Resampling.HAMMING,
Image.Resampling.BICUBIC, Image.Resampling.BICUBIC,
Image.Resampling.LANCZOS, Image.Resampling.LANCZOS,
Image.Resampling.MAGIC_KERNEL_SHARP_2021,
), ),
) )
def test_enlarge_filters(self, resample: Image.Resampling) -> None: def test_enlarge_filters(self, resample: Image.Resampling) -> None:
@ -104,6 +106,7 @@ class TestImagingCoreResize:
Image.Resampling.HAMMING, Image.Resampling.HAMMING,
Image.Resampling.BICUBIC, Image.Resampling.BICUBIC,
Image.Resampling.LANCZOS, Image.Resampling.LANCZOS,
Image.Resampling.MAGIC_KERNEL_SHARP_2021,
), ),
) )
@pytest.mark.parametrize( @pytest.mark.parametrize(

View File

@ -217,23 +217,36 @@ pixel, the Python Imaging Library provides different resampling *filters*.
.. versionadded:: 1.1.3 .. 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 Filters comparison table
~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~
+---------------------------+-------------+-----------+-------------+ +-------------------------------------------+-------------+------------+-------------+
| Filter | Downscaling | Upscaling | Performance | | Filter | Downscaling | Upscaling | Performance |
| | quality | quality | | | | quality | quality | |
+===========================+=============+===========+=============+ +===========================================+=============+============+=============+
|:data:`Resampling.NEAREST` | | | ⭐⭐⭐⭐⭐ | |:data:`Resampling.NEAREST` | | | ⭐⭐⭐⭐⭐ |
+---------------------------+-------------+-----------+-------------+ +-------------------------------------------+-------------+------------+-------------+
|:data:`Resampling.BOX` | ⭐ | | ⭐⭐⭐⭐ | |:data:`Resampling.BOX` | ⭐ | | ⭐⭐⭐⭐ |
+---------------------------+-------------+-----------+-------------+ +-------------------------------------------+-------------+------------+-------------+
|:data:`Resampling.BILINEAR`| ⭐ | ⭐ | ⭐⭐⭐ | |:data:`Resampling.BILINEAR` | ⭐ | ⭐ | ⭐⭐⭐ |
+---------------------------+-------------+-----------+-------------+ +-------------------------------------------+-------------+------------+-------------+
|:data:`Resampling.HAMMING` | ⭐⭐ | | ⭐⭐⭐ | |:data:`Resampling.HAMMING` | ⭐⭐ | | ⭐⭐⭐ |
+---------------------------+-------------+-----------+-------------+ +-------------------------------------------+-------------+------------+-------------+
|:data:`Resampling.BICUBIC` | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | |:data:`Resampling.BICUBIC` | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
+---------------------------+-------------+-----------+-------------+ +-------------------------------------------+-------------+------------+-------------+
|:data:`Resampling.LANCZOS` | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐ | |:data:`Resampling.LANCZOS` | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐ |
+---------------------------+-------------+-----------+-------------+ +-------------------------------------------+-------------+------------+-------------+
|:data:`Resampling.MAGIC_KERNEL_SHARP_2021` | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ |
+-------------------------------------------+-------------+------------+-------------+

View File

@ -151,6 +151,7 @@ class Resampling(IntEnum):
HAMMING = 5 HAMMING = 5
BICUBIC = 3 BICUBIC = 3
LANCZOS = 1 LANCZOS = 1
MAGIC_KERNEL_SHARP_2021 = 6
_filters_support = { _filters_support = {
@ -159,6 +160,7 @@ _filters_support = {
Resampling.HAMMING: 1.0, Resampling.HAMMING: 1.0,
Resampling.BICUBIC: 2.0, Resampling.BICUBIC: 2.0,
Resampling.LANCZOS: 3.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 :param resample: An optional resampling filter. This can be
one of :py:data:`Resampling.NEAREST`, :py:data:`Resampling.BOX`, one of :py:data:`Resampling.NEAREST`, :py:data:`Resampling.BOX`,
:py:data:`Resampling.BILINEAR`, :py:data:`Resampling.HAMMING`, :py:data:`Resampling.BILINEAR`, :py:data:`Resampling.HAMMING`,
:py:data:`Resampling.BICUBIC` or :py:data:`Resampling.LANCZOS`. :py:data:`Resampling.BICUBIC`, :py:data:`Resampling.LANCZOS` or
If the image has mode "1" or "P", it is always set to :py:data:`Resampling.MAGIC_KERNEL_SHARP_2021`. If the image has mode
:py:data:`Resampling.NEAREST`. Otherwise, the default filter is "1" or "P", it is always set to :py:data:`Resampling.NEAREST`.
:py:data:`Resampling.BICUBIC`. See: :ref:`concept-filters`. Otherwise, the default filter is :py:data:`Resampling.BICUBIC`. See:
:ref:`concept-filters`.
:param box: An optional 4-tuple of floats providing :param box: An optional 4-tuple of floats providing
the source image region to be scaled. the source image region to be scaled.
The values must be within (0, 0, width, height) rectangle. The values must be within (0, 0, width, height) rectangle.
@ -2245,6 +2248,7 @@ class Image:
Resampling.BILINEAR, Resampling.BILINEAR,
Resampling.BICUBIC, Resampling.BICUBIC,
Resampling.LANCZOS, Resampling.LANCZOS,
Resampling.MAGIC_KERNEL_SHARP_2021,
Resampling.BOX, Resampling.BOX,
Resampling.HAMMING, Resampling.HAMMING,
): ):
@ -2255,6 +2259,7 @@ class Image:
for filter in ( for filter in (
(Resampling.NEAREST, "Image.Resampling.NEAREST"), (Resampling.NEAREST, "Image.Resampling.NEAREST"),
(Resampling.LANCZOS, "Image.Resampling.LANCZOS"), (Resampling.LANCZOS, "Image.Resampling.LANCZOS"),
(Resampling.MAGIC_KERNEL_SHARP_2021, "Image.Resampling.MAGIC_KERNEL_SHARP_2021"),
(Resampling.BILINEAR, "Image.Resampling.BILINEAR"), (Resampling.BILINEAR, "Image.Resampling.BILINEAR"),
(Resampling.BICUBIC, "Image.Resampling.BICUBIC"), (Resampling.BICUBIC, "Image.Resampling.BICUBIC"),
(Resampling.BOX, "Image.Resampling.BOX"), (Resampling.BOX, "Image.Resampling.BOX"),
@ -2710,10 +2715,11 @@ class Image:
:param resample: Optional resampling filter. This can be one :param resample: Optional resampling filter. This can be one
of :py:data:`Resampling.NEAREST`, :py:data:`Resampling.BOX`, of :py:data:`Resampling.NEAREST`, :py:data:`Resampling.BOX`,
:py:data:`Resampling.BILINEAR`, :py:data:`Resampling.HAMMING`, :py:data:`Resampling.BILINEAR`, :py:data:`Resampling.HAMMING`,
:py:data:`Resampling.BICUBIC` or :py:data:`Resampling.LANCZOS`. :py:data:`Resampling.BICUBIC`, :py:data:`Resampling.LANCZOS` or
If omitted, it defaults to :py:data:`Resampling.BICUBIC`. :py:data:`Resampling.MAGIC_KERNEL_SHARP_2021`. If omitted, it
(was :py:data:`Resampling.NEAREST` prior to version 2.5.0). defaults to :py:data:`Resampling.BICUBIC`. (was
See: :ref:`concept-filters`. :py:data:`Resampling.NEAREST` prior to version 2.5.0). See:
:ref:`concept-filters`.
:param reducing_gap: Apply optimization by resizing the image :param reducing_gap: Apply optimization by resizing the image
in two steps. First, reducing the image by integer times in two steps. First, reducing the image by integer times
using :py:meth:`~PIL.Image.Image.reduce` or using :py:meth:`~PIL.Image.Image.reduce` or
@ -2924,11 +2930,12 @@ class Image:
Resampling.BILINEAR, Resampling.BILINEAR,
Resampling.BICUBIC, 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] = { unusable: dict[int, str] = {
Resampling.BOX: "Image.Resampling.BOX", Resampling.BOX: "Image.Resampling.BOX",
Resampling.HAMMING: "Image.Resampling.HAMMING", Resampling.HAMMING: "Image.Resampling.HAMMING",
Resampling.LANCZOS: "Image.Resampling.LANCZOS", Resampling.LANCZOS: "Image.Resampling.LANCZOS",
Resampling.MAGIC_KERNEL_SHARP_2021: "Image.Resampling.MAGIC_KERNEL_SHARP_2021",
} }
msg = unusable[resample] + f" ({resample}) cannot be used." msg = unusable[resample] + f" ({resample}) cannot be used."
else: else:

View File

@ -294,6 +294,7 @@ ImagingError_ValueError(const char *message);
#define IMAGING_TRANSFORM_HAMMING 5 #define IMAGING_TRANSFORM_HAMMING 5
#define IMAGING_TRANSFORM_BICUBIC 3 #define IMAGING_TRANSFORM_BICUBIC 3
#define IMAGING_TRANSFORM_LANCZOS 1 #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 (*ImagingTransformMap)(double *X, double *Y, int x, int y, void *data);
typedef int (*ImagingTransformFilter)(void *out, Imaging im, double x, double y); typedef int (*ImagingTransformFilter)(void *out, Imaging im, double x, double y);

View File

@ -79,11 +79,33 @@ lanczos_filter(double x) {
return 0.0; 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 BOX = {box_filter, 0.5};
static struct filter BILINEAR = {bilinear_filter, 1.0}; static struct filter BILINEAR = {bilinear_filter, 1.0};
static struct filter HAMMING = {hamming_filter, 1.0}; static struct filter HAMMING = {hamming_filter, 1.0};
static struct filter BICUBIC = {bicubic_filter, 2.0}; static struct filter BICUBIC = {bicubic_filter, 2.0};
static struct filter LANCZOS = {lanczos_filter, 3.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. /* 8 bits for result. Filter can have negative areas.
In one cases the sum of the coefficients will be negative, 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: case IMAGING_TRANSFORM_LANCZOS:
filterp = &LANCZOS; filterp = &LANCZOS;
break; break;
case IMAGING_TRANSFORM_MAGIC_KERNEL_SHARP_2021:
filterp = &MAGIC_KERNEL_SHARP_2021;
break;
default: default:
return (Imaging)ImagingError_ValueError("unsupported resampling filter"); return (Imaging)ImagingError_ValueError("unsupported resampling filter");
} }