diff --git a/README.md b/README.md index afd776d..6aed7c3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pixelmatch-py -Python port of https://github.com/mapbox/pixelmatch. +Python port of https://github.com/mapbox/pixelmatch with additional support of PIL.Image instances. A fast pixel-level image comparison library, originally created to compare screenshots in tests. @@ -40,39 +40,40 @@ python -m pip install pixelmatch Compares two images, writes the output diff and returns the number of mismatched pixels. -## Example usage +### contrib.PIL.pixelmatch + +Compares two images, writes the output diff and returns the number of mismatched pixels. Exact same API as `pixelmatch.pixelmatch` except for the important fact that it takes instances of PIL.Image for image parameters (`img1`, `img2`, and `output`) and the width/size need not be specified. -### PIL +## Example usage +### PIL.Image comparison ```python from PIL import Image -from pixelmatch import pixelmatch - - -def pil_to_flatten_data(img): - """ - Convert data from [(R1, G1, B1, A1), (R2, G2, B2, A2)] to [R1, G1, B1, A1, R2, G2, B2, A2] - """ - return [x for p in img.convert("RGBA").getdata() for x in p] +from pixelmatch.contrib.PIL import pixelmatch img_a = Image.open("a.png") img_b = Image.open("b.png") -width, height = img_a.size +img_diff = Image.new("RGBA", img_a.size) + +# note how there is no need to specify dimensions +mismatch = pixelmatch(img_a, img_b, img_diff, includeAA=True) + +img_diff.save("diff.png") +``` -data_a = pil_to_flatten_data(img_a) -data_b = pil_to_flatten_data(img_b) -data_diff = [0] * len(data_a) -mismatch = pixelmatch(data_a, data_b, width, height, data_diff, includeAA=True) +### Raw Image Data Comparison +```python +from pixelmatch import pixelmatch -img_diff = Image.new("RGBA", img_a.size) +width, height = 1920, 1080 +img_a = [R1, G1, B1, A1, R2, B2, G2, A2, ...] +img_b = [R1, G1, B1, A1, R2, B2, G2, A2, ...] -def flatten_data_to_pil(data): - return list(zip(data[::4], data[1::4], data[2::4], data[3::4])) +data_diff = [0] * len(img_a) -img_diff.putdata(flatten_data_to_pil(data_diff)) -img_diff.save("diff.png") +mismatch = pixelmatch(img_a, img_b, width, height, data_diff, includeAA=True) ``` ## Example output @@ -86,10 +87,13 @@ img_diff.save("diff.png") ## Changelog +### vNEXT + +- ft: add function to compare PIL.Image instances through contrib.PIL.pixelmatch [#42](https://github.com/whtsky/pixelmatch-py/pull/42) + ### v0.2.0 - BREAKING CHANGE: remove `options` parameter [#38](https://github.com/whtsky/pixelmatch-py/pull/38) - - docs: use absolute url for images in README ### v0.1.1 diff --git a/mypy.ini b/mypy.ini index 232015c..c616d7a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,4 +1,5 @@ [mypy] +warn_unused_configs = True warn_unused_ignores = True warn_unreachable = True warn_return_any = True @@ -6,5 +7,5 @@ warn_return_any = True [mypy-pytest] ignore_missing_imports = True -[mypy-PIL] +[mypy-PIL,PIL.*] ignore_missing_imports = True \ No newline at end of file diff --git a/pixelmatch/contrib/PIL.py b/pixelmatch/contrib/PIL.py new file mode 100644 index 0000000..61a8ed4 --- /dev/null +++ b/pixelmatch/contrib/PIL.py @@ -0,0 +1,93 @@ +"""Functions to facilitate direct comparison of PIL.Image instances""" +from typing import Optional, List, Tuple + +from PIL.Image import Image + +from pixelmatch import ( + pixelmatch as base_pixelmatch, + ImageSequence, + MutableImageSequence, +) +from pixelmatch import RGBTuple + + +def pixelmatch( + img1: Image, + img2: Image, + output: Optional[Image] = None, + threshold: float = 0.1, + includeAA: bool = False, + alpha: float = 0.1, + aa_color: RGBTuple = (255, 255, 0), + diff_color: RGBTuple = (255, 0, 0), + diff_mask: bool = False, +) -> int: + """ + Compares two images, writes the output diff and returns the number of mismatched pixels. + Serves the same purpose as pixelmatch.pixelmatch, but takes PIL.Images as input instead + of raw image data. + + :param img1: PIL.Image data to compare with img2. Must be the same size as img2 + :param img2: PIL.Image data to compare with img2. Must be the same size as img1 + :param output: Image data to write the diff to. Should be the same size as + :param threshold: matching threshold (0 to 1); smaller is more sensitive, defaults to 1 + :param includeAA: whether or not to skip anti-aliasing detection, ie if includeAA is True, + detecting and ignoring anti-aliased pixels is disabled. Defaults to False + :param alpha: opacity of original image in diff output, defaults to 0.1 + :param aa_color: tuple of RGB color of anti-aliased pixels in diff output, + defaults to (255, 255, 0) (yellow) + :param diff_color: tuple of RGB color of the color of different pixels in diff output, + defaults to (255, 0, 0) (red) + :param diff_mask: whether or not to draw the diff over a transparent background (a mask), + defaults to False + :return: number of pixels that are different + """ + width, height = img1.size + img1 = from_PIL_to_raw_data(img1) + img2 = from_PIL_to_raw_data(img2) + + if output is not None: + raw_output: Optional[MutableImageSequence] = from_PIL_to_raw_data(output) + else: + raw_output = None + + diff_pixels = base_pixelmatch( + img1, + img2, + width, + height, + raw_output, + threshold=threshold, + includeAA=includeAA, + alpha=alpha, + aa_color=aa_color, + diff_color=diff_color, + diff_mask=diff_mask, + ) + + if raw_output is not None and output is not None: + output.putdata(to_PIL_from_raw_data(raw_output)) + + return diff_pixels + + +def from_PIL_to_raw_data(pil_img: Image) -> MutableImageSequence: + """ + Converts a PIL.Image object from [(R1, B1, A1, A1), (R2, ...), ...] to our raw data format + [R1, G1, B1, A1, R2, ...]. + + :param pil_img: + :return: + """ + return [item for sublist in pil_img.convert("RGBA").getdata() for item in sublist] + + +def to_PIL_from_raw_data( + raw_data: ImageSequence, +) -> List[Tuple[float, float, float, float]]: + """ + Converts from the internal raw data format of [R1, G1, B1, A1, R2, ...] to PIL's raw data format, ie + [(R1, B1, A1, A1), (R2, ...), ...] + :return: raw image data in a PIL appropriate format + """ + return [*zip(raw_data[::4], raw_data[1::4], raw_data[2::4], raw_data[3::4])] diff --git a/pixelmatch/contrib/__init__.py b/pixelmatch/contrib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test_pixelmatch.py b/test_pixelmatch.py index 86b28e6..2320fb5 100644 --- a/test_pixelmatch.py +++ b/test_pixelmatch.py @@ -3,6 +3,7 @@ import pytest from PIL import Image +from pixelmatch.contrib import PIL from pixelmatch import pixelmatch @@ -77,3 +78,27 @@ def test_pixelmatch( assert diff_data == pil_to_flatten_data(expected_diff), "diff image" assert mismatch == expected_mismatch, "number of mismatched pixels" assert mismatch == mismatch2, "number of mismatched pixels without diff" + + +@pytest.mark.parametrize( + "img_path_1,img_path_2,diff_path,options,expected_mismatch", testdata +) +def test_PIL_pixelmatch( + img_path_1: str, + img_path_2: str, + diff_path: str, + options: Dict, + expected_mismatch: int, +): + img1 = read_img(img_path_1) + img2 = read_img(img_path_2) + diff_data = Image.new("RGBA", img1.size) + + mismatch = PIL.pixelmatch(img1, img2, diff_data, **options) + mismatch2 = PIL.pixelmatch(img1, img2, **options) + + assert pil_to_flatten_data(diff_data) == pil_to_flatten_data( + read_img(diff_path) + ), "diff image" + assert mismatch == expected_mismatch, "number of mismatched pixels" + assert mismatch == mismatch2, "number of mismatched pixels without diff"