diff --git a/Tests/benchmarks.py b/Tests/benchmarks.py index 0c67d298dcb..6ae017c8c5f 100644 --- a/Tests/benchmarks.py +++ b/Tests/benchmarks.py @@ -4,26 +4,34 @@ from __future__ import annotations +import os import pathlib +import re +import warnings from importlib.util import find_spec from io import BytesIO import pytest -from PIL import Image, ImageChops, ImageFilter -from PIL.Image import Resampling, Transpose +from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont +from PIL.Image import Resampling, Transform, Transpose TYPE_CHECKING = False if TYPE_CHECKING: from collections.abc import Callable - from pytest_benchmark.fixture import ( # type: ignore[import-not-found] + BenchmarkSave = Callable[[Image.Image], None] + + from pytest_benchmark.fixture import ( # type: ignore[unused-ignore, import-not-found] BenchmarkFixture, ) if not (find_spec("pytest_benchmark") or find_spec("pytest_codspeed")): pytest.skip("pytest-benchmark or pytest-codspeed required", allow_module_level=True) +_save_results = os.environ.get("PILLOW_BENCHMARK_SAVE_RESULTS_PATH") +SAVE_RESULTS_PATH = pathlib.Path(_save_results) if _save_results else None + # These can be adjusted to add more modes to benchmark # (however all features benchmarked might not support all PIL modes). MODES = ["RGB", "RGBA", "L", "LA"] @@ -75,6 +83,28 @@ def bench( return benchmark +@pytest.fixture +def benchmark_save(request: pytest.FixtureRequest) -> BenchmarkSave: + """ + Fixture to save a benchmark image, if so configured. + """ + + def save(im: Image.Image) -> None: + if SAVE_RESULTS_PATH: + safe_name = re.sub("[^-a-zA-Z0-9]", "_", str(request.node.name)) + name = (SAVE_RESULTS_PATH / safe_name).with_suffix(".png") + try: + SAVE_RESULTS_PATH.mkdir(parents=True, exist_ok=True) + im.save(name) + except Exception as e: + warnings.warn( + f"Failed to save benchmark result to {name}: {e}", + stacklevel=2, + ) + + return save + + def make_pillow_image( mode: str, size: tuple[int, int], @@ -87,6 +117,35 @@ def make_pillow_image( return im.convert(mode) +BBOX_SCENARIOS: dict[str, tuple[float, str] | None] = { + "half-centered": (0.5, "center"), + "small-centered": (0.1, "center"), + "half-corner": (0.5, "corner"), + "empty": None, +} + + +def make_bbox_image( + mode: str, + size: tuple[int, int], + region: tuple[float, str] | None, +) -> Image.Image: + im = Image.new(mode, size, 0) + if region is None: + return im + fraction, placement = region + w, h = size + bw, bh = max(1, round(w * fraction)), max(1, round(h * fraction)) + if placement == "center": + left, top = (w - bw) // 2, (h - bh) // 2 + else: + left, top = w - bw, h - bh + nbands = len(im.getbands()) + color = (255, 128, 64, 255)[:nbands] if nbands > 1 else 255 + im.paste(color, (left, top, left + bw - 1, top + bh - 1)) + return im + + @pytest.mark.benchmark(group="composition") @pytest.mark.parametrize("mode", MODES) @pytest.mark.parametrize("size", SIZES, ids=_format_size) @@ -119,6 +178,40 @@ def test_scale( bench(im.resize, dest, resampler) +REDUCE_FACTORS = [ + (1, 2), # ImagingReduce1x2 + (1, 3), # ImagingReduce1x3 + (1, 7), # ImagingReduce1xN + (2, 1), # ImagingReduce2x1 + (2, 2), # ImagingReduce2x2 + (3, 1), # ImagingReduce3x1 + (3, 3), # ImagingReduce3x3 + (3, 5), # ImagingReduceNxN + (4, 4), # ImagingReduce4x4 + (5, 5), # ImagingReduce5x5 + (7, 1), # ImagingReduceNx1 +] + + +@pytest.mark.benchmark(group="scale") +@pytest.mark.parametrize("factor", REDUCE_FACTORS, ids=lambda f: f"{f[0]}x{f[1]}") +@pytest.mark.parametrize("mode", SCALE_MODES) +@pytest.mark.parametrize("size", SIZES, ids=_format_size) +def test_reduce( + bench: BenchmarkFixture, + mode: str, + size: tuple[int, int], + factor: tuple[int, int], +) -> None: + im = make_pillow_image(mode, size) + bench.extra_info["label"] = [f"reduce {factor[0]}x{factor[1]}"] + result = bench(im.reduce, factor) + assert result.size == ( + -(-im.width // factor[0]), + -(-im.height // factor[1]), + ) + + @pytest.mark.benchmark(group="blur") @pytest.mark.parametrize("radius", [1, 10, 30]) @pytest.mark.parametrize("mode", MODES) @@ -163,10 +256,38 @@ def test_alpha_composite( @pytest.mark.parametrize( "mode_from, mode_to", [ + # bilevel <-> greyscale (in-place path: l2bit / bit2l) + ("L", "1"), + ("1", "L"), + # greyscale <-> colour (pack / expand) ("RGB", "L"), + ("L", "RGB"), + ("RGB", "1"), + ("LA", "RGBA"), + # channel add / drop / premultiply + ("RGB", "RGBA"), + ("RGBA", "RGB"), ("RGBA", "LA"), ("RGBa", "RGBA"), ("RGBA", "RGBa"), + # palette expansion (p2rgb / p2rgba / pa2rgb / pa2rgba) + ("P", "RGB"), + ("P", "RGBA"), + ("PA", "RGB"), + ("PA", "RGBA"), + # colorspace conversions + ("RGB", "YCbCr"), + ("YCbCr", "RGB"), + ("RGB", "HSV"), + ("HSV", "RGB"), + ("RGB", "CMYK"), + ("CMYK", "RGB"), + # integer / float modes + ("L", "I"), + ("I", "L"), + ("L", "F"), + ("F", "L"), + ("RGB", "I;16"), ], ) @pytest.mark.parametrize("size", SIZES, ids=_format_size) @@ -218,7 +339,7 @@ def test_crop( ], ids=lambda f: f.name, ) -@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("mode", [*MODES, "I;16"]) @pytest.mark.parametrize("size", SIZES, ids=_format_size) def test_filter( bench: BenchmarkFixture, @@ -231,6 +352,29 @@ def test_filter( bench(im.filter, filter) +@pytest.mark.benchmark(group="filter") +@pytest.mark.parametrize( + "rank_filter", + [ + ImageFilter.MedianFilter(3), + ImageFilter.MedianFilter(5), + ImageFilter.MinFilter(3), + ], + ids=lambda f: f"{f.name}{f.size}", +) +@pytest.mark.parametrize("mode", ["L", "I", "F"]) +@pytest.mark.parametrize("size", SIZES, ids=_format_size) +def test_rank_filter( + bench: BenchmarkFixture, + mode: str, + size: tuple[int, int], + rank_filter: ImageFilter.RankFilter, +) -> None: + im = make_pillow_image(mode, size) + bench.extra_info["label"] = [f"{rank_filter.name}{rank_filter.size}"] + bench(im.filter, rank_filter) + + @pytest.mark.benchmark(group="lut") @pytest.mark.parametrize( "channels, table_size", @@ -266,6 +410,36 @@ def test_lut( bench(im.filter, lut) +@pytest.mark.benchmark(group="transform") +@pytest.mark.parametrize( + "method, data", + [ + (Transform.AFFINE, (1.1, 0.1, -20, 0.05, 0.95, -10)), + (Transform.PERSPECTIVE, (1.0, 0.1, -20, 0.05, 1.0, -10, 0.0002, 0.0001)), + (Transform.QUAD, (10, 20, 5, 1000, 1010, 1005, 1005, 8)), + ], + ids=["AFFINE", "PERSPECTIVE", "QUAD"], +) +@pytest.mark.parametrize( + "resample", + [Resampling.NEAREST, Resampling.BILINEAR, Resampling.BICUBIC], + ids=lambda r: r.name, +) +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("size", SIZES, ids=_format_size) +def test_transform( + bench: BenchmarkFixture, + mode: str, + size: tuple[int, int], + method: Transform, + data: tuple[float, ...], + resample: Resampling, +) -> None: + im = make_pillow_image(mode, size) + bench.extra_info["label"] = [method.name, resample.name] + bench(im.transform, size, method, data, resample) + + @pytest.mark.benchmark(group="rotate_right") @pytest.mark.parametrize("op", Transpose, ids=lambda t: t.name) @pytest.mark.parametrize("mode", MODES) @@ -281,6 +455,88 @@ def test_rotate_right( bench(im.transpose, op) +@pytest.mark.benchmark(group="draw") +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("size", SIZES, ids=_format_size) +def test_draw_rectangle( + bench: BenchmarkFixture, + mode: str, + size: tuple[int, int], +) -> None: + # Exercise various fill functions. + im = Image.new(mode, size) + draw = ImageDraw.Draw(im) + nbands = len(im.getbands()) + ink = (200, 50, 25, 255)[:nbands] if nbands > 1 else 200 + w, h = size + bench(lambda: draw.rectangle((0, 0, w - 1, h - 1), fill=ink)) + + +@pytest.mark.benchmark(group="draw") +@pytest.mark.parametrize("size", SIZES, ids=_format_size) +def test_draw_rectangle_blend(bench: BenchmarkFixture, size: tuple[int, int]) -> None: + # Exercise fill functions with blending. + im = Image.new("RGB", size) + draw = ImageDraw.Draw(im, "RGBA") + w, h = size + bench(lambda: draw.rectangle((0, 0, w - 1, h - 1), fill=(200, 50, 25, 127))) + + +@pytest.mark.benchmark(group="draw") +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("size", SIZES, ids=_format_size) +def test_draw_polygon( + bench: BenchmarkFixture, + mode: str, + size: tuple[int, int], +) -> None: + # Exercise the polygon scanline filler, which is not the rectangle fast path. + im = Image.new(mode, size) + draw = ImageDraw.Draw(im) + nbands = len(im.getbands()) + ink = (200, 50, 25, 255)[:nbands] if nbands > 1 else 200 + w, h = size + xy = [(0, 0), (w - 1, 30), (w - 20, h - 1), (10, h - 20)] + bench(lambda: draw.polygon(xy, fill=ink)) + + +def _zigzag(size: tuple[int, int], segments: int = 16) -> list[tuple[int, int]]: + w, h = size + return [ + (0 if i % 2 == 0 else w - 1, i * (h - 1) // segments) + for i in range(segments + 1) + ] + + +@pytest.mark.benchmark(group="draw") +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("size", SIZES, ids=_format_size) +def test_draw_lines(bench: BenchmarkFixture, mode: str, size: tuple[int, int]) -> None: + # Exercise point-by-point line drawing (a polyline is a single C call). + im = Image.new(mode, size) + draw = ImageDraw.Draw(im) + nbands = len(im.getbands()) + ink = (200, 50, 25, 255)[:nbands] if nbands > 1 else 200 + xy = _zigzag(size) + bench(lambda: draw.line(xy, fill=ink)) + + +@pytest.mark.benchmark(group="draw") +@pytest.mark.parametrize("alpha", [0, 127, 255]) +@pytest.mark.parametrize("size", SIZES, ids=_format_size) +def test_draw_lines_blend( + bench: BenchmarkFixture, + size: tuple[int, int], + alpha: int, +) -> None: + # Exercise line drawing with blending; alpha 0 and 255 have fast paths. + im = Image.new("RGB", size) + draw = ImageDraw.Draw(im, "RGBA") + xy = _zigzag(size) + bench.extra_info["label"] = [f"alpha={alpha}"] + bench(lambda: draw.line(xy, fill=(200, 50, 25, alpha))) + + @pytest.mark.benchmark(group="load") @pytest.mark.parametrize("path", PATHS, ids=_format_path) def test_load(bench: BenchmarkFixture, path: pathlib.Path) -> None: @@ -431,6 +687,30 @@ def test_offset(bench: BenchmarkFixture, mode: str, size: tuple[int, int]) -> No bench(ImageChops.offset, im, 123, 45) +@pytest.mark.benchmark(group="extrema") +@pytest.mark.parametrize("mode", ["L", "LA", "I", "F", "RGB", "RGBA", "CMYK"]) +@pytest.mark.parametrize("size", SIZES, ids=_format_size) +def test_getextrema(bench: BenchmarkFixture, mode: str, size: tuple[int, int]) -> None: + im = make_pillow_image(mode, size) + bench.extra_info["label"] = [f"extrema {mode}"] + bench(im.getextrema) + + +@pytest.mark.benchmark(group="bbox") +@pytest.mark.parametrize("scenario", list(BBOX_SCENARIOS)) +@pytest.mark.parametrize("mode", ["L", "LA", "I;16", "RGB", "RGBA"]) +@pytest.mark.parametrize("size", SIZES, ids=_format_size) +def test_getbbox( + bench: BenchmarkFixture, + mode: str, + size: tuple[int, int], + scenario: str, +) -> None: + im = make_bbox_image(mode, size, BBOX_SCENARIOS[scenario]) + bench.extra_info["label"] = [scenario] + bench(im.getbbox) + + @pytest.mark.benchmark(group="histogram") @pytest.mark.parametrize("mode", MODES) @pytest.mark.parametrize("size", SIZES, ids=_format_size) @@ -488,7 +768,7 @@ def test_point_lut(bench: BenchmarkFixture, mode: str, size: tuple[int, int]) -> @pytest.mark.benchmark(group="point") -@pytest.mark.parametrize("mode", ["I", "F"]) +@pytest.mark.parametrize("mode", ["I", "F", "LA", "RGBA"]) @pytest.mark.parametrize("size", SIZES, ids=_format_size) def test_point_transform( bench: BenchmarkFixture, mode: str, size: tuple[int, int] @@ -498,6 +778,16 @@ def test_point_transform( bench(im.point, lambda v: v * 1.5 + 3.0) +@pytest.mark.benchmark(group="font") +@pytest.mark.parametrize("mode", ["", "1", "L"]) +def test_font_getmask(bench: BenchmarkFixture, mode: str) -> None: + font = ImageFont.load_default_imagefont() + text = "The quick brown fox jumps over the lazy dog. " * 8 + bench.extra_info["label"] = [f"getmask mode {mode!r}"] + mask = bench(font.getmask, text, mode) + assert mask.size[0] > 0 + + @pytest.mark.benchmark(group="quantize") @pytest.mark.parametrize("mode", [m for m in MODES if m in ("L", "RGB", "RGBA")]) @pytest.mark.parametrize("size", SIZES, ids=_format_size) @@ -506,3 +796,75 @@ def test_quantize(bench: BenchmarkFixture, mode: str, size: tuple[int, int]) -> bench.extra_info["label"] = [f"quantize {mode}"] result = bench(im.quantize, 256) assert result.mode == "P" + + +@pytest.mark.benchmark(group="quantize") +@pytest.mark.parametrize("output_mode", ["P", "PA"]) +@pytest.mark.parametrize("size", SIZES, ids=_format_size) +def test_quantize_grayscale_to_palette( + bench: BenchmarkFixture, + output_mode: str, + size: tuple[int, int], +) -> None: + im = make_pillow_image("L", size) + bench.extra_info["label"] = [f"quantize L to {output_mode}"] + result = bench(im.convert, output_mode) + assert result.mode == output_mode + assert result.convert("L").tobytes() == im.tobytes() + + +@pytest.mark.benchmark(group="quantize") +@pytest.mark.parametrize( + "dither", + [Image.Dither.NONE, Image.Dither.FLOYDSTEINBERG], + ids=["none", "floyd-steinberg"], +) +@pytest.mark.parametrize("output_mode", ["P", "PA"]) +@pytest.mark.parametrize( + "source_type", + [ + "synthetic", + *(pytest.param(image, id=f"{image.stem}") for image in PATHS), + ], +) +@pytest.mark.parametrize("palette_type", ["exact", "grayscale", "web"]) +@pytest.mark.parametrize("size", SIZES, ids=_format_size) +def test_quantize_to_palette( + bench: BenchmarkFixture, + benchmark_save: BenchmarkSave, + dither: Image.Dither, + output_mode: str, + source_type: str | pathlib.Path, + palette_type: str, + size: tuple[int, int], +) -> None: + if isinstance(source_type, pathlib.Path): + im = Image.open(source_type).convert("RGB").resize(size) + elif source_type == "synthetic": + im = make_pillow_image("RGB", size) + if palette_type == "exact": + palette = im.quantize(256) + im = palette.convert("RGB") + elif palette_type == "web": + palette = Image.new("RGB", (1, 1)).convert( + "P", + palette=Image.Palette.WEB, + dither=Image.Dither.NONE, + ) + else: + palette = Image.new("P", (1, 1)) + palette.putpalette(tuple(channel for i in range(256) for channel in (i, i, i))) + + bench.extra_info["label"] = [ + ( + f"{source_type} RGB to {output_mode}, " + f"{palette_type} palette, " + f"{dither.name.lower()} dither" + ), + ] + if output_mode == "P": + result = bench(im.quantize, palette=palette, dither=dither) + else: + result = bench(lambda: im._new(im.im.convert(output_mode, dither, palette.im))) + assert result.mode == output_mode + benchmark_save(result)