From adc0e9af8962b1716128fbd9ebab156fc6da0d3e Mon Sep 17 00:00:00 2001 From: Zihan Dai Date: Sat, 3 Oct 2026 19:28:24 +1000 Subject: [PATCH 1/2] Forward fillcolor to compatible image transform handlers --- Tests/test_image_transform.py | 148 ++++++++++++++++++++++++++++++++++ docs/releasenotes/13.0.0.rst | 17 ++++ src/PIL/Image.py | 25 +++++- 3 files changed, 188 insertions(+), 2 deletions(-) diff --git a/Tests/test_image_transform.py b/Tests/test_image_transform.py index 576310b7fd2..79d57d7c20b 100644 --- a/Tests/test_image_transform.py +++ b/Tests/test_image_transform.py @@ -11,6 +11,7 @@ TYPE_CHECKING = False if TYPE_CHECKING: from collections.abc import Callable + from typing import Any class TestImageTransform: @@ -45,6 +46,153 @@ def test_info(self) -> None: new_im = im.transform((100, 100), transform) assert new_im.info["comment"] == comment + @pytest.mark.parametrize("mode", ("L", "RGB", "RGBA", "LA")) + @pytest.mark.parametrize( + "resample", + (Image.Resampling.NEAREST, Image.Resampling.BILINEAR, Image.Resampling.BICUBIC), + ) + @pytest.mark.parametrize( + "transform", + ( + ImageTransform.AffineTransform((1, 0, -1, 0, 1, 0)), + ImageTransform.PerspectiveTransform((1, 0, -1, 0, 1, 0, 0, 0)), + ImageTransform.ExtentTransform((-1, 0, 1, 1)), + ImageTransform.QuadTransform((-1, 0, -1, 1, 1, 1, 1, 0)), + ImageTransform.MeshTransform([((0, 0, 2, 1), (-1, 0, -1, 1, 1, 1, 1, 0))]), + ), + ) + def test_handler_fillcolor( + self, mode: str, resample: Image.Resampling, transform: ImageTransform.Transform + ) -> None: + im = Image.new(mode, (2, 1), "white") + transformed = im.transform( + im.size, transform, resample=resample, fillcolor="red" + ) + method, data = transform.getdata() + expected = im.transform( + im.size, method, data, resample=resample, fillcolor="red" + ) + assert transformed.getpixel((0, 0)) == Image.new(mode, (1, 1), "red").getpixel( + (0, 0) + ) + assert_image_equal(transformed, expected) + + @pytest.mark.parametrize("fillcolor", (None, 0, "red", (1, 2, 3))) + def test_handler_options( + self, fillcolor: int | str | tuple[int, ...] | None + ) -> None: + options_seen: dict[str, Any] = {} + + class CustomTransform(ImageTransform.AffineTransform): + def transform( + self, size: tuple[int, int], image: Image.Image, **options: Any + ) -> Image.Image: + options_seen.update(options) + assert size == image.size + return image + + im = Image.new("RGB", (2, 1)) + transformed = im.transform( + im.size, + CustomTransform((1, 0, 0, 0, 1, 0)), + resample=Image.Resampling.BICUBIC, + fill=0, + fillcolor=fillcolor, + ) + assert transformed is im + expected: dict[str, Any] = {"resample": Image.Resampling.BICUBIC, "fill": 0} + if fillcolor is not None: + expected["fillcolor"] = fillcolor + assert options_seen == expected + + def test_legacy_handler_fillcolor(self) -> None: + class LegacyTransform(Image.ImageTransformHandler): + # Preserve the documented legacy signature without **options. + def transform( # type: ignore[override] + self, + size: tuple[int, int], + image: Image.Image, + resample: int, + fill: int = 1, + ) -> Image.Image: + return image + + im = Image.new("RGB", (2, 1)) + transform = LegacyTransform() + assert im.transform(im.size, transform) is im + assert im.transform(im.size, transform, fillcolor=None) is im + assert im.transform(im.size, transform, fillcolor="red") is im + + def test_handler_keyword_fillcolor(self) -> None: + class Handler(Image.ImageTransformHandler): + def transform( # type: ignore[override] + self, + size: tuple[int, int], + image: Image.Image, + resample: int, + fill: int = 1, + *, + fillcolor: Any = None, + ) -> Image.Image: + assert fillcolor == "red" + return image + + im = Image.new("RGB", (2, 1)) + assert im.transform(im.size, Handler(), fillcolor="red") is im + + def test_handler_none_does_not_inspect(self) -> None: + class Transform: + @property + def __signature__(self) -> None: + message = "default calls must not inspect signatures" + raise AssertionError(message) + + def __call__( + self, size: tuple[int, int], image: Image.Image, **options: Any + ) -> Image.Image: + assert "fillcolor" not in options + return image + + class Handler(Image.ImageTransformHandler): + transform = Transform() + + im = Image.new("RGB", (2, 1)) + assert im.transform(im.size, Handler()) is im + assert im.transform(im.size, Handler(), fillcolor=None) is im + + def test_handler_fillcolor_positional_collision(self) -> None: + class Handler(Image.ImageTransformHandler): + # This legacy handler calls its positional image argument fillcolor. + def transform( # type: ignore[override] + self, + size: tuple[int, int], + fillcolor: Image.Image, + resample: int = 0, + fill: int = 1, + ) -> Image.Image: + return fillcolor + + im = Image.new("RGB", (2, 1)) + assert im.transform(im.size, Handler(), fillcolor="red") is im + + def test_handler_typeerror_not_retried(self) -> None: + calls: list[Any] = [] + error = TypeError("error from inside the handler") + + class Handler(Image.ImageTransformHandler): + def transform( + self, size: tuple[int, int], image: Image.Image, **options: Any + ) -> Image.Image: + calls.append(options) + raise error + + im = Image.new("RGB", (2, 1)) + with pytest.raises(TypeError) as raised: + im.transform(im.size, Handler(), fillcolor="red") + assert raised.value is error + assert len(calls) == 1 + assert calls[0]["fillcolor"] == "red" + @pytest.mark.parametrize("mode", ("P", "PA")) def test_palette(self, mode: str) -> None: im = hopper(mode) diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 54ef1308952..59346b58c1d 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -99,6 +99,23 @@ intended for internal use, so there is no replacement. API changes =========== +ImageTransformHandler fillcolor +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:meth:`~PIL.Image.Image.transform` now passes a non-``None`` ``fillcolor`` +argument to :py:class:`~PIL.Image.ImageTransformHandler` objects when the +handler's callable signature accepts the keyword, explicitly or through +``**kwargs``. The built-in :py:mod:`~PIL.ImageTransform` classes now use the +requested background color, instead of silently ignoring it. + +Legacy handlers without keyword support, and handlers whose signatures cannot +be inspected, retain the previous behavior of ignoring ``fillcolor``. Calls +that omit ``fillcolor`` or set it to ``None`` use the existing handler arguments. +Signature inspection uses the callable itself rather than following its +``__wrapped__`` attribute. Custom handlers are responsible for exposing an +accurate signature and for handling any options that they accept. + + TODO ^^^^ diff --git a/src/PIL/Image.py b/src/PIL/Image.py index a62eb198831..210ca269c1d 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -39,6 +39,7 @@ import abc import atexit import builtins +import inspect import io import math import os @@ -2949,7 +2950,7 @@ def transform( object:: class Example(Image.ImageTransformHandler): - def transform(self, size, data, resample, fill=1): + def transform(self, size, data, resample, fill=1, fillcolor=None): # Return result Implementations of :py:class:`~PIL.Image.ImageTransformHandler` @@ -2977,6 +2978,11 @@ def getdata(self): the arguments passed to it. Otherwise, it is unused. :param fillcolor: Optional fill color for the area outside the transform in the output image. + If ``method`` is an :py:class:`~PIL.Image.ImageTransformHandler` + object and ``fillcolor`` is not ``None``, it is passed to the + handler's ``transform`` method as a keyword argument if its + signature accepts it. Handlers without a compatible or inspectable + signature continue to ignore ``fillcolor``. :returns: An :py:class:`~PIL.Image.Image` object. """ @@ -2988,7 +2994,22 @@ def getdata(self): ) if isinstance(method, ImageTransformHandler): - return method.transform(size, self, resample=resample, fill=fill) + transform = method.transform + if fillcolor is not None: + try: + signature = inspect.signature(transform, follow_wrapped=False) + signature.bind( + size, self, resample=resample, fill=fill, fillcolor=fillcolor + ) + except (TypeError, ValueError): + # Preserve the old call if the signature is unavailable or + # the fillcolor keyword cannot be added to these arguments. + pass + else: + return transform( + size, self, resample=resample, fill=fill, fillcolor=fillcolor + ) + return transform(size, self, resample=resample, fill=fill) if hasattr(method, "getdata"): # compatibility w. old-style transform objects From 26d033369fa508c440966eae02ebfa54ccfd8450 Mon Sep 17 00:00:00 2001 From: Zihan Dai Date: Sun, 11 Oct 2026 01:18:09 +1100 Subject: [PATCH 2/2] Forward fillcolor only through the default ImageTransform handler Remove signature inspection. A non-None fillcolor is now passed only when the handler's bound transform method is ImageTransform.Transform.transform, so built-in handlers and subclasses that inherit it (including those that override getdata) honour the color. Custom class or instance transform overrides keep receiving the existing arguments. --- Tests/test_image_transform.py | 136 ++++++++++++++++++++++++++++++++-- docs/releasenotes/13.0.0.rst | 30 ++++---- src/PIL/Image.py | 30 +++----- 3 files changed, 153 insertions(+), 43 deletions(-) diff --git a/Tests/test_image_transform.py b/Tests/test_image_transform.py index 79d57d7c20b..08ab675d83e 100644 --- a/Tests/test_image_transform.py +++ b/Tests/test_image_transform.py @@ -1,6 +1,7 @@ from __future__ import annotations import math +from functools import wraps import pytest @@ -101,8 +102,6 @@ def transform( ) assert transformed is im expected: dict[str, Any] = {"resample": Image.Resampling.BICUBIC, "fill": 0} - if fillcolor is not None: - expected["fillcolor"] = fillcolor assert options_seen == expected def test_legacy_handler_fillcolor(self) -> None: @@ -121,6 +120,7 @@ def transform( # type: ignore[override] transform = LegacyTransform() assert im.transform(im.size, transform) is im assert im.transform(im.size, transform, fillcolor=None) is im + assert im.transform(im.size, transform, fillcolor=0) is im assert im.transform(im.size, transform, fillcolor="red") is im def test_handler_keyword_fillcolor(self) -> None: @@ -134,17 +134,23 @@ def transform( # type: ignore[override] *, fillcolor: Any = None, ) -> Image.Image: - assert fillcolor == "red" + assert fillcolor is None return image im = Image.new("RGB", (2, 1)) assert im.transform(im.size, Handler(), fillcolor="red") is im - def test_handler_none_does_not_inspect(self) -> None: + @pytest.mark.parametrize("fillcolor", (None, 0, "red")) + def test_handler_does_not_inspect(self, fillcolor: int | str | None) -> None: class Transform: @property def __signature__(self) -> None: - message = "default calls must not inspect signatures" + message = "custom calls must not inspect signatures" + raise AssertionError(message) + + @property + def __func__(self) -> None: + message = "custom calls must not inspect method metadata" raise AssertionError(message) def __call__( @@ -158,7 +164,7 @@ class Handler(Image.ImageTransformHandler): im = Image.new("RGB", (2, 1)) assert im.transform(im.size, Handler()) is im - assert im.transform(im.size, Handler(), fillcolor=None) is im + assert im.transform(im.size, Handler(), fillcolor=fillcolor) is im def test_handler_fillcolor_positional_collision(self) -> None: class Handler(Image.ImageTransformHandler): @@ -191,7 +197,123 @@ def transform( im.transform(im.size, Handler(), fillcolor="red") assert raised.value is error assert len(calls) == 1 - assert calls[0]["fillcolor"] == "red" + assert calls[0] == {"resample": Image.Resampling.NEAREST, "fill": 1} + + @pytest.mark.parametrize("override_getdata", (False, True)) + @pytest.mark.parametrize("fillcolor", (None, 0, "red")) + def test_inherited_handler_fillcolor( + self, override_getdata: bool, fillcolor: int | str | None + ) -> None: + data = (1, 0, -1, 0, 1, 0) + calls = [] + + class Inherited(ImageTransform.AffineTransform): + pass + + class GetDataOverride(Inherited): + def getdata(self) -> tuple[Image.Transform, tuple[int, ...]]: + calls.append(True) + return Image.Transform.AFFINE, data + + handler: ImageTransform.Transform = Inherited(data) + if override_getdata: + handler = GetDataOverride((1, 0, 0, 0, 1, 0)) + im = Image.new("RGB", (2, 1), "white") + actual = im.transform(im.size, handler, fillcolor=fillcolor) + expected = im.transform( + im.size, Image.Transform.AFFINE, data, fillcolor=fillcolor + ) + assert_image_equal(actual, expected) + assert calls == ([True] if override_getdata else []) + + @pytest.mark.parametrize("fillcolor", (0, "red")) + def test_default_handler_forwards_fillcolor( + self, monkeypatch: pytest.MonkeyPatch, fillcolor: int | str + ) -> None: + im = Image.new("RGB", (2, 1), "white") + transform = im.transform + calls = [] + + def callback( + size: tuple[int, int], method: Image.Transform, data: Any, **options: Any + ) -> Image.Image: + calls.append(options) + return im + + monkeypatch.setattr(im, "transform", callback) + handler = ImageTransform.AffineTransform((1, 0, -1, 0, 1, 0)) + assert transform(im.size, handler, fillcolor=fillcolor) is im + assert calls == [ + {"resample": Image.Resampling.NEAREST, "fill": 1, "fillcolor": fillcolor} + ] + + def test_instance_handler_override(self, monkeypatch: pytest.MonkeyPatch) -> None: + im = Image.new("RGB", (2, 1), "white") + output = Image.new("RGB", im.size, "blue") + calls = [] + + def override( + size: tuple[int, int], image: Image.Image, **options: Any + ) -> Image.Image: + calls.append(options) + return output + + handler = ImageTransform.AffineTransform((1, 0, -1, 0, 1, 0)) + monkeypatch.setattr(handler, "transform", override) + assert im.transform(im.size, handler, fillcolor="red") is output + assert calls == [{"resample": Image.Resampling.NEAREST, "fill": 1}] + + @pytest.mark.parametrize("fillcolor", (None, 0, "red")) + def test_wrapped_legacy_handler(self, fillcolor: int | str | None) -> None: + calls = [] + + class Legacy(Image.ImageTransformHandler): + def transform( # type: ignore[override] + self, + size: tuple[int, int], + image: Image.Image, + resample: int, + fill: int = 1, + ) -> Image.Image: + calls.append((resample, fill)) + return image + + class Wrapped(Legacy): + @wraps(Legacy.transform) + def transform( # type: ignore[override] + self, *args: Any, **kwargs: Any + ) -> Image.Image: + return super().transform(*args, **kwargs) + + im = Image.new("RGB", (2, 1), "white") + assert im.transform(im.size, Wrapped(), fillcolor=fillcolor) is im + assert calls == [(Image.Resampling.NEAREST, 1)] + + def test_getdata_only_fillcolor(self) -> None: + calls = [] + data = (1, 0, -1, 0, 1, 0) + + class GetData: + def getdata(self) -> tuple[Image.Transform, tuple[int, ...]]: + calls.append(True) + return Image.Transform.AFFINE, data + + im = Image.new("RGB", (2, 1), "white") + actual = im.transform(im.size, GetData(), fillcolor="red") + expected = im.transform(im.size, Image.Transform.AFFINE, data, fillcolor="red") + assert_image_equal(actual, expected) + assert calls == [True] + + def test_transform_only_unsupported(self) -> None: + class TransformOnly: + def transform( + self, size: tuple[int, int], image: Image.Image, **options: Any + ) -> Image.Image: + pytest.fail("a non-handler transform must not be called") + + im = Image.new("RGB", (2, 1), "white") + with pytest.raises(ValueError, match="missing method data"): + im.transform(im.size, TransformOnly(), fillcolor="red") # type: ignore[arg-type] @pytest.mark.parametrize("mode", ("P", "PA")) def test_palette(self, mode: str) -> None: diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 59346b58c1d..bb4dfc3e5fa 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -99,23 +99,6 @@ intended for internal use, so there is no replacement. API changes =========== -ImageTransformHandler fillcolor -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -:py:meth:`~PIL.Image.Image.transform` now passes a non-``None`` ``fillcolor`` -argument to :py:class:`~PIL.Image.ImageTransformHandler` objects when the -handler's callable signature accepts the keyword, explicitly or through -``**kwargs``. The built-in :py:mod:`~PIL.ImageTransform` classes now use the -requested background color, instead of silently ignoring it. - -Legacy handlers without keyword support, and handlers whose signatures cannot -be inspected, retain the previous behavior of ignoring ``fillcolor``. Calls -that omit ``fillcolor`` or set it to ``None`` use the existing handler arguments. -Signature inspection uses the callable itself rather than following its -``__wrapped__`` attribute. Custom handlers are responsible for exposing an -accurate signature and for handling any options that they accept. - - TODO ^^^^ @@ -252,3 +235,16 @@ Fixed reading PSD layers at the wrong file offset When seeking to a layer of a PSD image, the pixel data was read from the wrong position in the file, so :py:meth:`~PIL.Image.Image.seek` returned unrelated bytes reinterpreted as image data instead of that layer's actual content. + +Fixed fill colors for built-in transform handlers +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:meth:`~PIL.Image.Image.transform` now passes a non-``None`` ``fillcolor`` +to built-in :py:mod:`~PIL.ImageTransform` handlers and subclasses that inherit +``Transform.transform``. This also preserves overridden ``getdata`` methods. +Previously, these handlers discarded the color, unlike the equivalent +transform-enum and data call. + +Custom class or instance ``transform`` overrides keep their existing arguments, +without ``fillcolor``, even if their signature accepts it. Omitted or ``None`` +colors retain the previous behavior. diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 210ca269c1d..34d9d0e6913 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -39,7 +39,6 @@ import abc import atexit import builtins -import inspect import io import math import os @@ -49,6 +48,7 @@ import warnings from collections.abc import MutableMapping from enum import IntEnum +from types import MethodType from typing import IO, Protocol, cast # VERSION was removed in Pillow 6.0.0. @@ -2950,7 +2950,7 @@ def transform( object:: class Example(Image.ImageTransformHandler): - def transform(self, size, data, resample, fill=1, fillcolor=None): + def transform(self, size, data, resample, fill=1): # Return result Implementations of :py:class:`~PIL.Image.ImageTransformHandler` @@ -2977,12 +2977,11 @@ def getdata(self): :py:class:`~PIL.Image.ImageTransformHandler` object, this is one of the arguments passed to it. Otherwise, it is unused. :param fillcolor: Optional fill color for the area outside the - transform in the output image. - If ``method`` is an :py:class:`~PIL.Image.ImageTransformHandler` - object and ``fillcolor`` is not ``None``, it is passed to the - handler's ``transform`` method as a keyword argument if its - signature accepts it. Handlers without a compatible or inspectable - signature continue to ignore ``fillcolor``. + transform in the output image. This is passed to the built-in + :py:mod:`~PIL.ImageTransform` handlers and subclasses that inherit + their default ``transform`` implementation. Custom ``transform`` + overrides retain the existing handler arguments, without + ``fillcolor``. :returns: An :py:class:`~PIL.Image.Image` object. """ @@ -2995,17 +2994,10 @@ def getdata(self): if isinstance(method, ImageTransformHandler): transform = method.transform - if fillcolor is not None: - try: - signature = inspect.signature(transform, follow_wrapped=False) - signature.bind( - size, self, resample=resample, fill=fill, fillcolor=fillcolor - ) - except (TypeError, ValueError): - # Preserve the old call if the signature is unavailable or - # the fillcolor keyword cannot be added to these arguments. - pass - else: + if fillcolor is not None and type(transform) is MethodType: + from . import ImageTransform + + if transform.__func__ is ImageTransform.Transform.transform: return transform( size, self, resample=resample, fill=fill, fillcolor=fillcolor )