From 4467ae0bc0c99a01a91d20c49b5a4c4b22d6ed3e Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Sat, 17 Oct 2026 18:46:11 -0700 Subject: [PATCH] Preserve translucent fill colors in image transforms --- Tests/test_image_transform.py | 61 +++++++++++++++++++++++++++++++++++ docs/releasenotes/13.1.0.rst | 12 +++++++ docs/releasenotes/index.rst | 1 + src/PIL/Image.py | 27 ++++++++++++---- 4 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 docs/releasenotes/13.1.0.rst diff --git a/Tests/test_image_transform.py b/Tests/test_image_transform.py index 576310b7fd2..23bca9e5dc2 100644 --- a/Tests/test_image_transform.py +++ b/Tests/test_image_transform.py @@ -108,6 +108,67 @@ def test_fill(self, mode: str, expected_pixel: tuple[int, ...]) -> None: ) assert transformed.getpixel((w - 1, h - 1)) == expected_pixel + @pytest.mark.parametrize("mode", ("RGBA", "LA")) + @pytest.mark.parametrize("alpha", (0, 1, 128, 255)) + @pytest.mark.parametrize( + "resample", (Image.Resampling.BILINEAR, Image.Resampling.BICUBIC) + ) + @pytest.mark.parametrize( + "method, data", + ( + (Image.Transform.AFFINE, (1, 0, -1, 0, 1, -1)), + (Image.Transform.EXTENT, (-1, -1, 5, 5)), + (Image.Transform.PERSPECTIVE, (1, 0, -1, 0, 1, -1, 0, 0)), + (Image.Transform.QUAD, (-1, -1, -1, 5, 5, 5, 5, -1)), + (Image.Transform.MESH, [((0, 0, 5, 5), (-1, -1, -1, 4, 4, 4, 4, -1))]), + ), + ) + def test_fill_alpha( + self, + mode: str, + alpha: int, + resample: Image.Resampling, + method: Image.Transform, + data: tuple[int, ...] | list[tuple[tuple[int, ...], tuple[int, ...]]], + ) -> None: + color = (128, 64, 32, alpha) if mode == "RGBA" else (64, alpha) + im = Image.new(mode, (3, 3), "white") + transformed = im.transform((6, 6), method, data, resample, fillcolor=color) + assert transformed.getpixel((0, 0)) == color + assert transformed.getpixel((5, 5)) == color + assert transformed.getpixel((2, 2)) == im.getpixel((1, 1)) + + def test_fill_getdata(self) -> None: + class OldStyleTransform: + calls = 0 + + def getdata(self) -> tuple[Image.Transform, tuple[int, ...]]: + self.calls += 1 + return Image.Transform.AFFINE, (1, 0, -1, 0, 1, -1) + + transform = OldStyleTransform() + color = (128, 64, 32, 128) + im = Image.new("RGBA", (3, 3), "white") + transformed = im.transform( + (5, 5), transform, resample=Image.Resampling.BILINEAR, fillcolor=color + ) + assert transform.calls == 1 + assert transformed.getpixel((0, 0)) == color + + def test_fill_overlapping_mesh(self) -> None: + im = Image.new("RGBA", (3, 3), "white") + transformed = im.transform( + im.size, + Image.Transform.MESH, + [ + ((0, 0, 3, 3), (0, 0, 0, 3, 3, 3, 3, 0)), + ((0, 0, 3, 3), (-10, -10, -10, -7, -7, -7, -7, -10)), + ], + Image.Resampling.BILINEAR, + fillcolor=(128, 64, 32, 128), + ) + assert_image_equal(transformed, im) + def test_mesh(self) -> None: # this should be a checkerboard of halfsized hoppers in ul, lr im = hopper("RGBA") diff --git a/docs/releasenotes/13.1.0.rst b/docs/releasenotes/13.1.0.rst new file mode 100644 index 00000000000..49bd96fa3c5 --- /dev/null +++ b/docs/releasenotes/13.1.0.rst @@ -0,0 +1,12 @@ +13.1.0 +------ + +Other changes +============= + +Preserve translucent fill colors during transforms +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +For ``RGBA`` and ``LA`` images, bilinear and bicubic transforms now preserve +the requested ``fillcolor`` outside the source image, including when called +through :py:meth:`~PIL.Image.Image.rotate`. diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst index e45919afb07..bc0cb895d92 100644 --- a/docs/releasenotes/index.rst +++ b/docs/releasenotes/index.rst @@ -15,6 +15,7 @@ expected to be backported to earlier versions. :maxdepth: 2 versioning + 13.1.0 13.0.0 12.3.0 12.2.0 diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 40720c5d438..399b2dde6c6 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2981,20 +2981,35 @@ def getdata(self): :returns: An :py:class:`~PIL.Image.Image` object. """ + if not isinstance(method, ImageTransformHandler) and hasattr(method, "getdata"): + # Resolve old-style transform objects once, including for the fill mask. + method, data = method.getdata() + if self.mode in ("LA", "RGBA") and resample != Resampling.NEAREST: - return ( + im = ( self.convert({"LA": "La", "RGBA": "RGBa"}[self.mode]) - .transform(size, method, data, resample, fill, fillcolor) + .transform( + size, + method, + data, + resample, + fill, + 0 if fillcolor is not None else None, + ) .convert(self.mode) ) + if fillcolor is not None and not isinstance(method, ImageTransformHandler): + # Fill colors belong to the original mode. Applying them after + # conversion also avoids losing their precision to premultiplication. + mask = new("L", self.size).transform( + size, method, data, resample, fillcolor=255 + ) + im.paste(fillcolor, mask=mask) + return im if isinstance(method, ImageTransformHandler): return method.transform(size, self, resample=resample, fill=fill) - if hasattr(method, "getdata"): - # compatibility w. old-style transform objects - method, data = method.getdata() - if data is None: msg = "missing method data" raise ValueError(msg)