Skip to content

Add support for Magic Kernel Sharp resizing modes #8811

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
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
70 changes: 70 additions & 0 deletions Tests/test_image_resample.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,32 @@
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_mks2013(self, mode: str) -> None:
case = self.make_case(mode, (16, 16), 0xE1)
case = case.resize((8, 8), Image.Resampling.MKS2013)
# fmt: off
data = ("e1 e1 e9 dc"
"e1 e1 e9 dc"
"e9 e9 f1 e3"
"dc dc e4 d8")
# fmt: on
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_mks2021(self, mode: str) -> None:
case = self.make_case(mode, (16, 16), 0xE1)
case = case.resize((8, 8), Image.Resampling.MKS2021)
# fmt: off
data = ("e1 e1 e3 d7"
"e1 e1 e3 d7"
"e3 e3 e5 d9"
"d7 d7 d9 ce")
# fmt: on
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_enlarge_box(self, mode: str) -> None:
case = self.make_case(mode, (2, 2), 0xE1)
Expand Down Expand Up @@ -226,6 +252,36 @@
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_mks2013(self, mode: str) -> None:
case = self.make_case(mode, (6, 6), 0xE1)
case = case.resize((12, 12), Image.Resampling.MKS2013)
data = (
"e1 e1 e2 ef fb be"
"e1 e1 e2 ef fb be"
"e2 e2 e3 f1 fd bf"
"ef ef f0 ff ff c7"
"fb fb fc ff ff cf"
"be be bf c7 cf a8"
)
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_mks2021(self, mode: str) -> None:
case = self.make_case(mode, (6, 6), 0xE1)
case = case.resize((12, 12), Image.Resampling.MKS2021)
data = (
"e3 e1 df e9 f5 bb"
"e1 df dd e7 f3 b9"
"df dd db e5 f1 b8"
"e9 e7 e5 ef fc be"
"f5 f3 f0 fc ff c5"
"bb ba b8 bf c6 a3"
)
for channel in case.split():
self.check_case(channel, self.make_sample(data, (12, 12)))

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 +365,8 @@
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.MKS2013))
self.run_levels_case(case.resize((512, 32), Image.Resampling.MKS2021))

Check warning on line 369 in Tests/test_image_resample.py

View check run for this annotation

Codecov / codecov/patch

Tests/test_image_resample.py#L368-L369

Added lines #L368 - L369 were not covered by tests

@pytest.mark.xfail(reason="Current implementation isn't precise enough")
def test_levels_la(self) -> None:
Expand All @@ -318,6 +376,8 @@
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.MKS2013))
self.run_levels_case(case.resize((512, 32), Image.Resampling.MKS2021))

Check warning on line 380 in Tests/test_image_resample.py

View check run for this annotation

Codecov / codecov/patch

Tests/test_image_resample.py#L379-L380

Added lines #L379 - L380 were not covered by tests

def make_dirty_case(
self, mode: str, clean_pixel: tuple[int, ...], dirty_pixel: tuple[int, ...]
Expand Down Expand Up @@ -360,6 +420,12 @@
self.run_dirty_case(
case.resize((20, 20), Image.Resampling.LANCZOS), (255, 255, 0)
)
self.run_dirty_case(
case.resize((20, 20), Image.Resampling.MKS2013), (255, 255, 0)
)
self.run_dirty_case(
case.resize((20, 20), Image.Resampling.MKS2021), (255, 255, 0)
)

def test_dirty_pixels_la(self) -> None:
case = self.make_dirty_case("LA", (255, 128), (0, 0))
Expand All @@ -368,6 +434,8 @@
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.MKS2013), (255,))
self.run_dirty_case(case.resize((20, 20), Image.Resampling.MKS2021), (255,))


class TestCoreResamplePasses:
Expand Down Expand Up @@ -453,6 +521,8 @@
Image.Resampling.HAMMING,
Image.Resampling.BICUBIC,
Image.Resampling.LANCZOS,
Image.Resampling.MKS2013,
Image.Resampling.MKS2021,
),
)
def test_wrong_arguments(self, resample: Image.Resampling) -> None:
Expand Down
8 changes: 8 additions & 0 deletions Tests/test_image_resize.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ def test_convolution_modes(self) -> None:
Image.Resampling.HAMMING,
Image.Resampling.BICUBIC,
Image.Resampling.LANCZOS,
Image.Resampling.MKS2013,
Image.Resampling.MKS2021,
),
)
def test_reduce_filters(self, resample: Image.Resampling) -> None:
Expand All @@ -88,6 +90,8 @@ def test_reduce_filters(self, resample: Image.Resampling) -> None:
Image.Resampling.HAMMING,
Image.Resampling.BICUBIC,
Image.Resampling.LANCZOS,
Image.Resampling.MKS2013,
Image.Resampling.MKS2021,
),
)
def test_enlarge_filters(self, resample: Image.Resampling) -> None:
Expand All @@ -104,6 +108,8 @@ def test_enlarge_filters(self, resample: Image.Resampling) -> None:
Image.Resampling.HAMMING,
Image.Resampling.BICUBIC,
Image.Resampling.LANCZOS,
Image.Resampling.MKS2013,
Image.Resampling.MKS2021,
),
)
@pytest.mark.parametrize(
Expand Down Expand Up @@ -154,6 +160,8 @@ def test_endianness(
Image.Resampling.HAMMING,
Image.Resampling.BICUBIC,
Image.Resampling.LANCZOS,
Image.Resampling.MKS2013,
Image.Resampling.MKS2021,
),
)
def test_enlarge_zero(self, resample: Image.Resampling) -> None:
Expand Down
21 changes: 21 additions & 0 deletions docs/handbook/concepts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,23 @@ pixel, the Python Imaging Library provides different resampling *filters*.

.. versionadded:: 1.1.3

.. data:: Resampling.MKS2013
:noindex:

Calculate the output pixel value using the Magic Kernel Sharp 2013 filter
(a quadratic B-spline composed with a sharpening kernel) on all pixels that
may contribute to the output value. This filter can only be used with the
:py:meth:`~PIL.Image.Image.resize` and :py:meth:`~PIL.Image.Image.thumbnail`
methods.

.. data:: Resampling.MKS2021
:noindex:

Calculate the output pixel value using the Magic Kernel Sharp 2021 filter
(a quadratic B-spline composed with a sharpening kernel) on all pixels that
may contribute to the output value. This filter can only be used with the
:py:meth:`~PIL.Image.Image.resize` and :py:meth:`~PIL.Image.Image.thumbnail`
methods.

Filters comparison table
~~~~~~~~~~~~~~~~~~~~~~~~
Expand All @@ -215,3 +232,7 @@ Filters comparison table
+---------------------------+-------------+-----------+-------------+
|:data:`Resampling.LANCZOS` | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐ |
+---------------------------+-------------+-----------+-------------+
|:data:`Resampling.MKS2013` | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐ |
+---------------------------+-------------+-----------+-------------+
|:data:`Resampling.MKS2021` | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐| ⭐ |
+---------------------------+-------------+-----------+-------------+
24 changes: 21 additions & 3 deletions src/PIL/Image.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ class Resampling(IntEnum):
HAMMING = 5
BICUBIC = 3
LANCZOS = 1
MKS2013 = 6
MKS2021 = 7


_filters_support = {
Expand All @@ -177,6 +179,8 @@ class Resampling(IntEnum):
Resampling.HAMMING: 1.0,
Resampling.BICUBIC: 2.0,
Resampling.LANCZOS: 3.0,
Resampling.MKS2013: 2.5,
Resampling.MKS2021: 4.5,
}


Expand Down Expand Up @@ -2207,7 +2211,8 @@ 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`.
:py:data:`Resampling.BICUBIC`, :py:data:`Resampling.LANCZOS`,
:py:data:`Resampling.MKS2013`, or :py:data:`Resampling.MKS2021`.
If the image has mode "1" or "P", it is always set to
:py:data:`Resampling.NEAREST`. If the image mode is "BGR;15",
"BGR;16" or "BGR;24", then the default filter is
Expand Down Expand Up @@ -2242,6 +2247,8 @@ def resize(
Resampling.LANCZOS,
Resampling.BOX,
Resampling.HAMMING,
Resampling.MKS2013,
Resampling.MKS2021,
):
msg = f"Unknown resampling filter ({resample})."

Expand All @@ -2254,6 +2261,8 @@ def resize(
(Resampling.BICUBIC, "Image.Resampling.BICUBIC"),
(Resampling.BOX, "Image.Resampling.BOX"),
(Resampling.HAMMING, "Image.Resampling.HAMMING"),
(Resampling.MKS2013, "Image.Resampling.MKS2013"),
(Resampling.MKS2021, "Image.Resampling.MKS2021"),
)
]
msg += f" Use {', '.join(filters[:-1])} or {filters[-1]}"
Expand Down Expand Up @@ -2690,7 +2699,8 @@ 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`.
:py:data:`Resampling.BICUBIC`, :py:data:`Resampling.LANCZOS`,
:py:data:`Resampling.MKS2013`, or :py:data:`Resampling.MKS2021`.
If omitted, it defaults to :py:data:`Resampling.BICUBIC`.
(was :py:data:`Resampling.NEAREST` prior to version 2.5.0).
See: :ref:`concept-filters`.
Expand Down Expand Up @@ -2904,11 +2914,19 @@ 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.MKS2013,
Resampling.MKS2021,
):
unusable: dict[int, str] = {
Resampling.BOX: "Image.Resampling.BOX",
Resampling.HAMMING: "Image.Resampling.HAMMING",
Resampling.LANCZOS: "Image.Resampling.LANCZOS",
Resampling.MKS2013: "Image.Resampling.MKS2013",
Resampling.MKS2021: "Image.Resampling.MKS2021",
}
msg = unusable[resample] + f" ({resample}) cannot be used."
else:
Expand Down
2 changes: 2 additions & 0 deletions src/libImaging/Imaging.h
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,8 @@ ImagingError_Clear(void);
#define IMAGING_TRANSFORM_HAMMING 5
#define IMAGING_TRANSFORM_BICUBIC 3
#define IMAGING_TRANSFORM_LANCZOS 1
#define IMAGING_TRANSFORM_MKS2013 6
#define IMAGING_TRANSFORM_MKS2021 7

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
50 changes: 50 additions & 0 deletions src/libImaging/Resample.c
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,55 @@ lanczos_filter(double x) {
return 0.0;
}

static inline double
mks_2013_filter(double x) {
/* https://johncostella.com/magic/ */
if (x < 0) {
x = -x;
}
if (x < 0.5) {
return 17.0 / 16.0 - 7.0 / 4.0 * pow(x, 2);
}
if (x < 1.5) {
return (1.0 - x) * (7.0 / 4.0 - x);
}
if (x < 2.5) {
return -1.0 / 8.0 * pow(x - 5.0 / 2.0, 2);
}
return 0;
}

static inline double
mks_2021_filter(double x) {
/* https://johncostella.com/magic/ */
if (x < 0) {
x = -x;
}
if (x < 0.5) {
return 577.0 / 576.0 - 239.0 / 144.0 * pow(x, 2);
}
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 - 15.0 / 4.0);
}
if (x < 4.5) {
return -1.0 / 288.0 * pow(x - 9.0 / 2.0, 2);
}
return 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 MKS2013 = {mks_2013_filter, 2.5};
static struct filter MKS2021 = {mks_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 @@ -693,6 +737,12 @@ ImagingResample(Imaging imIn, int xsize, int ysize, int filter, float box[4]) {
case IMAGING_TRANSFORM_LANCZOS:
filterp = &LANCZOS;
break;
case IMAGING_TRANSFORM_MKS2013:
filterp = &MKS2013;
break;
case IMAGING_TRANSFORM_MKS2021:
filterp = &MKS2021;
break;
default:
return (Imaging)ImagingError_ValueError("unsupported resampling filter");
}
Expand Down
Loading