Skip to content

Commit c173be4

Browse files
brianhelbawhtsky
andauthored
Improve the performance of PIL.pixelmatch (#181)
* Improve the performance of `PIL.pixelmatch` This adds a PIL-specific fast path that detects identical images, then renders the grayscale output with pure-PIL operations. This is most of the performance gain. This also slightly improves performance in the general case by: * using a zeroed `bytearray`, instead of a transformed `output` (since the initial value of `output` shouldn't actually matter) * avoiding a call to `to_PIL_from_raw_data` (and associated pure-Python loops), by using `Image.frombytes` Benchmarks on 360x360 test images, with `output` requested: - Identical images, `diff_mask=False`: 33ms to 0.36ms (~90x faster) - Identical images, `diff_mask=True`: 33ms to 0.21ms (~150x faster, since no rendering occurs) - Different images: 170ms to 165ms * Remove `PIL.from_PIL_to_raw_data`, `PIL.to_PIL_from_raw_data` These functions are no longer needed internally (more efficient bytes-based PIL operations are now used) and provide no external value. * tests: split PIL fixtures from core for identical-image cases --------- Co-authored-by: Wu Haotian <whtsky@gmail.com>
1 parent 49f70af commit c173be4

10 files changed

Lines changed: 54 additions & 50 deletions

fixtures/1a_identical_alpha01.png

Lines changed: 0 additions & 3 deletions
This file was deleted.

fixtures/1a_identical_alpha05.png

Lines changed: 0 additions & 3 deletions
This file was deleted.

fixtures/1a_identical_alpha10.png

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading

fixtures/6empty_pil.png

Lines changed: 3 additions & 0 deletions
Loading

pixelmatch/contrib/PIL.py

Lines changed: 35 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""Functions to facilitate direct comparison of PIL.Image instances"""
22

3-
from typing import List, Optional, Tuple
3+
from typing import Optional
44

5+
import PIL.Image
56
from PIL.Image import Image
67

78
from pixelmatch import core
8-
from pixelmatch.types import ImageSequence, MutableImageSequence, RGBTuple
9+
from pixelmatch.types import RGBTuple
910

1011

1112
def pixelmatch(
@@ -26,9 +27,9 @@ def pixelmatch(
2627
of raw image data.
2728
2829
:param img1: PIL.Image data to compare with img2. Must be the same size as img2
29-
:param img2: PIL.Image data to compare with img2. Must be the same size as img1
30-
:param output: Image data to write the diff to. Should be the same size as
31-
:param threshold: matching threshold (0 to 1); smaller is more sensitive, defaults to 1
30+
:param img2: PIL.Image data to compare with img1. Must be the same size as img1
31+
:param output: RGBA Image to write the diff to. Must be the same size as img1
32+
:param threshold: matching threshold (0 to 1); smaller is more sensitive, defaults to 0.1
3233
:param includeAA: whether or not to skip anti-aliasing detection, ie if includeAA is True,
3334
detecting and ignoring anti-aliased pixels is disabled. Defaults to False
3435
:param alpha: opacity of original image in diff output, defaults to 0.1
@@ -42,20 +43,29 @@ def pixelmatch(
4243
:return: number of pixels that are different or 1 if fail_fast == true
4344
"""
4445
width, height = img1.size
45-
raw_img1 = from_PIL_to_raw_data(img1)
46-
raw_img2 = from_PIL_to_raw_data(img2)
46+
img1_rgba = img1.convert("RGBA")
47+
img2_rgba = img2.convert("RGBA")
48+
img1_bytes = img1_rgba.tobytes()
49+
img2_bytes = img2_rgba.tobytes()
4750

48-
if output is not None:
49-
raw_output: Optional[MutableImageSequence] = from_PIL_to_raw_data(output)
50-
else:
51-
raw_output = None
51+
# Fast path: byte-identical images have no diff
52+
if img1_bytes == img2_bytes:
53+
if output is not None and not diff_mask:
54+
_draw_grayscale(img1_rgba, alpha, output)
55+
return 0
56+
57+
# core.pixelmatch doesn't read output_data, so initialize with zeros;
58+
# img1 should be the same size as output (and will error in .frombytes otherwise)
59+
output_data = bytearray(len(img1_bytes)) if output is not None else None
5260

5361
diff_pixels = core.pixelmatch(
54-
raw_img1,
55-
raw_img2,
62+
list(img1_bytes),
63+
list(img2_bytes),
5664
width,
5765
height,
58-
raw_output,
66+
# bytearray is MutableSequence[int], not MutableSequence[int | float];
67+
# safe because draw_pixel() only ever writes int values
68+
output_data, # type: ignore[arg-type]
5969
threshold=threshold,
6070
includeAA=includeAA,
6171
alpha=alpha,
@@ -65,29 +75,18 @@ def pixelmatch(
6575
fail_fast=fail_fast,
6676
)
6777

68-
if raw_output is not None and output is not None:
69-
output.putdata(to_PIL_from_raw_data(raw_output))
78+
if output_data is not None and output is not None:
79+
output.frombytes(bytes(output_data), "raw", "RGBA")
7080

7181
return diff_pixels
7282

7383

74-
def from_PIL_to_raw_data(pil_img: Image) -> MutableImageSequence:
75-
"""
76-
Converts a PIL.Image object from [(R1, B1, A1, A1), (R2, ...), ...] to our raw data format
77-
[R1, G1, B1, A1, R2, ...].
78-
79-
:param pil_img:
80-
:return:
81-
"""
82-
return list(pil_img.convert("RGBA").tobytes())
83-
84-
85-
def to_PIL_from_raw_data(
86-
raw_data: ImageSequence,
87-
) -> List[Tuple[float, float, float, float]]:
88-
"""
89-
Converts from the internal raw data format of [R1, G1, B1, A1, R2, ...] to PIL's raw data format, ie
90-
[(R1, B1, A1, A1), (R2, ...), ...]
91-
:return: raw image data in a PIL appropriate format
92-
"""
93-
return [*zip(raw_data[::4], raw_data[1::4], raw_data[2::4], raw_data[3::4])]
84+
def _draw_grayscale(img_rgba: Image, alpha: float, output: Image) -> None:
85+
"""Draw a grayscale version of img_rgba blended with white into output."""
86+
luminance = img_rgba.convert("L")
87+
# Opaque pixels get weight `alpha`, transparent pixels get 0.
88+
blend_weight = img_rgba.getchannel("A").point(lambda x: int(x * alpha))
89+
white = PIL.Image.new("L", img_rgba.size, 255)
90+
# Where mask is high, show luminance; where low, show white.
91+
blended = PIL.Image.composite(image1=luminance, image2=white, mask=blend_weight)
92+
output.paste(PIL.Image.merge("RGBA", (blended, blended, blended, white)))

test_pixelmatch.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ def pil_to_flatten_data(img):
5151
["6a", "6a", "6empty", {"threshold": 0}, 0],
5252
["7a", "7b", "7diff", OPTIONS, 9856],
5353
]
54+
pil_testdata = list(testdata)
55+
pil_testdata[8] = ["6a", "6a", "6empty_pil", {"threshold": 0}, 0]
5456

5557

5658
@pytest.mark.parametrize(
@@ -115,7 +117,7 @@ def test_pixelmatch_failfast(
115117

116118

117119
@pytest.mark.parametrize(
118-
"img_path_1,img_path_2,diff_path,options,expected_mismatch", testdata
120+
"img_path_1,img_path_2,diff_path,options,expected_mismatch", pil_testdata
119121
)
120122
def test_PIL_pixelmatch(
121123
img_path_1: str,
@@ -156,10 +158,10 @@ def test_PIL_pixelmatch_identical_images_diff_mask_output_stays_blank(benchmark)
156158
@pytest.mark.parametrize(
157159
"alpha,fixture",
158160
[
159-
(0.0, "1a_identical_alpha00"),
160-
(0.1, "1a_identical_alpha01"),
161-
(0.5, "1a_identical_alpha05"),
162-
(1.0, "1a_identical_alpha10"),
161+
(0.0, "1a_identical_pil_alpha00"),
162+
(0.1, "1a_identical_pil_alpha01"),
163+
(0.5, "1a_identical_pil_alpha05"),
164+
(1.0, "1a_identical_pil_alpha10"),
163165
],
164166
)
165167
def test_PIL_pixelmatch_identical_images_output_matches_core(alpha, fixture, benchmark):

0 commit comments

Comments
 (0)