Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ mismatch = pixelmatch(img_a, img_b, width, height, data_diff, includeAA=True)

## API

### pixelmatch(img1, img2, width, height, output, threshold, includeAA, alpha, aa_color, diff_color, diff_mask, fail_fast)
### pixelmatch(img1, img2, width, height, output, threshold, includeAA, alpha, aa_color, diff_color, diff_mask, fail_fast, ignore_mask)

- `img1`, `img2` — RGBA Image data of the images to compare. **Note:** image dimensions must be equal.
- `width`, `height` — Width and height of the images.
Expand All @@ -71,12 +71,13 @@ mismatch = pixelmatch(img_a, img_b, width, height, data_diff, includeAA=True)
- `diff_color` — The color of differing pixels in the diff output in `[R, G, B]` format. `[255, 0, 0]` by default.
- `diff_mask` — Draw the diff over a transparent background (a mask), rather than over the original image. Will not draw anti-aliased pixels (if detected).
- `fail_fast` - If true, will return after first different pixel.
- `ignore_mask` — A sequence with one value per pixel (`width * height` long, e.g. a `list[int]` or `bytes`); pixels with a non-zero value are excluded from the comparison and drawn as unchanged in the diff output. `None` by default.

Compares two images, writes the output diff and returns the number of mismatched pixels.

### 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.
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. `ignore_mask` can be either a PIL.Image of the same size (converted to grayscale, so any non-black pixel is ignored) or a sequence with one value per pixel.

## Example output

Expand Down
22 changes: 21 additions & 1 deletion pixelmatch/contrib/PIL.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Functions to facilitate direct comparison of PIL.Image instances"""

from typing import Optional
from typing import Optional, Sequence, Union

import PIL.Image
from PIL.Image import Image
Expand All @@ -20,6 +20,7 @@
diff_color: RGBTuple = (255, 0, 0),
diff_mask: bool = False,
fail_fast: bool = False,
ignore_mask: Optional[Union[Image, Sequence[int]]] = None,

Check warning on line 23 in pixelmatch/contrib/PIL.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a union type expression for this type hint.

See more on https://sonarcloud.io/project/issues?id=whtsky_pixelmatch-py&issues=AaFlYkOAZeDylSsDxErX&open=AaFlYkOAZeDylSsDxErX&pullRequest=268
) -> int:
"""
Compares two images, writes the output diff and returns the number of mismatched pixels.
Expand All @@ -40,9 +41,27 @@
:param diff_mask: whether or not to draw the diff over a transparent background (a mask),
defaults to False
:param fail_fast: if true, will return after first different pixel. Defaults to false
:param ignore_mask: PIL.Image of the same size as img1 (converted to grayscale "L") or a
sequence with one value per pixel; pixels with a non-zero value are excluded from the
comparison and drawn as unchanged in the diff output. Defaults to None
:return: number of pixels that are different or 1 if fail_fast == true
"""
width, height = img1.size
ignore_mask_data: Optional[Sequence[int]]
if isinstance(ignore_mask, Image):
if ignore_mask.size != img1.size:
raise ValueError(
"Ignore mask size does not match img1.", ignore_mask.size, img1.size
)
ignore_mask_data = ignore_mask.convert("L").tobytes()
else:
ignore_mask_data = ignore_mask
if ignore_mask_data is not None and len(ignore_mask_data) != width * height:
raise ValueError(
"Ignore mask size does not match width/height.",
len(ignore_mask_data),
width * height,
)
img1_rgba = img1.convert("RGBA")
img2_rgba = img2.convert("RGBA")
img1_bytes = img1_rgba.tobytes()
Expand Down Expand Up @@ -73,6 +92,7 @@
diff_color=diff_color,
diff_mask=diff_mask,
fail_fast=fail_fast,
ignore_mask=ignore_mask_data,
)

if output_data is not None and output is not None:
Expand Down
31 changes: 26 additions & 5 deletions pixelmatch/core.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Optional
from typing import Optional, Sequence

from .types import ImageSequence, MutableImageSequence, RGBTuple
from .utils import antialiased, color_delta, draw_gray_pixel, draw_pixel
Expand All @@ -17,6 +17,7 @@ def pixelmatch(
diff_color: RGBTuple = (255, 0, 0),
diff_mask: bool = False,
fail_fast: bool = False,
ignore_mask: Optional[Sequence[int]] = None,
) -> int:
"""
Compares two images, writes the output diff and returns the number of mismatched pixels.
Expand All @@ -39,6 +40,9 @@ def pixelmatch(
:param diff_mask: whether or not to draw the diff over a transparent background (a mask),
defaults to False
:param fail_fast: if true, will return after first different pixel. Defaults to false
:param ignore_mask: one value per pixel (``width * height`` long); pixels with a non-zero
value are excluded from the comparison and drawn as unchanged in the diff output.
Defaults to None (compare every pixel)
:return: number of pixels that are different or 1 if fail_fast == true
"""

Expand All @@ -56,8 +60,21 @@ def pixelmatch(
width * height * 4,
)

# fast path if identical
if img1 == img2:
if ignore_mask is not None and len(ignore_mask) != width * height:
raise ValueError(
"Ignore mask size does not match width/height.",
len(ignore_mask),
width * height,
)

# fast path if identical (ignoring masked pixels)
if img1 == img2 or (
ignore_mask is not None
and all(
ignore_mask[i] or img1[4 * i : 4 * i + 4] == img2[4 * i : 4 * i + 4]
for i in range(width * height)
)
):
if output and not diff_mask:
for i in range(width * height):
draw_gray_pixel(img1, 4 * i, alpha, output)
Expand All @@ -77,8 +94,12 @@ def pixelmatch(
for x in range(width):
pos = (y * width + x) * 4

# squared YUV distance between colors at this pixel position
delta = color_delta(img1, img2, pos, pos)
# squared YUV distance between colors at this pixel position;
# pixels covered by the ignore mask are treated as unchanged
if ignore_mask is not None and ignore_mask[y * width + x]:
delta = 0.0
else:
delta = color_delta(img1, img2, pos, pos)

# the color difference is above the threshold
if delta > maxDelta:
Expand Down
139 changes: 139 additions & 0 deletions test_pixelmatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,142 @@
pil_output = Image.new("RGBA", img.size)
benchmark(PIL.pixelmatch, img, img, pil_output, threshold=0, alpha=alpha)
assert pil_to_flatten_data(pil_output) == pil_to_flatten_data(read_img(fixture))


def _half_ignore_mask(width: int, height: int):
"""Mask the first half of the pixels (top half of the image), like upstream's tests."""
size = width * height
return [1 if i <= size / 2 else 0 for i in range(size)]


def _pixels(data, width: int, height: int):
return [tuple(data[4 * i : 4 * i + 4]) for i in range(width * height)]


def test_pixelmatch_ignore_mask():
img1 = read_img("1a")
img2 = read_img("1b")
width, height = img1.size
img1_data = pil_to_flatten_data(img1)
img2_data = pil_to_flatten_data(img2)
ignore_mask = _half_ignore_mask(width, height)

unmasked_output = [0.0] * len(img1_data)
pixelmatch(img1_data, img2_data, width, height, unmasked_output, **OPTIONS)
unchanged_output = [0.0] * len(img1_data)
pixelmatch(img1_data, img1_data, width, height, unchanged_output, **OPTIONS)

masked_output = [0.0] * len(img1_data)
mismatch = pixelmatch(
img1_data,
img2_data,
width,
height,
masked_output,
ignore_mask=ignore_mask,
**OPTIONS,
)

unmasked_pixels = _pixels(unmasked_output, width, height)
masked_pixels = _pixels(masked_output, width, height)
unchanged_pixels = _pixels(unchanged_output, width, height)
diff_in_unmasked_area = sum(
1
for i, pixel in enumerate(unmasked_pixels)
if not ignore_mask[i] and pixel == (255, 0, 0, 255)
)

assert 0 < mismatch < 143
assert mismatch == diff_in_unmasked_area
for i in range(width * height):
if ignore_mask[i]:
assert masked_pixels[i] == unchanged_pixels[i], "masked pixel drawn"
else:
assert masked_pixels[i] == unmasked_pixels[i], "unmasked pixel changed"
assert mismatch == pixelmatch(
img1_data, img2_data, width, height, ignore_mask=ignore_mask, **OPTIONS
)


def test_pixelmatch_ignore_mask_fast_path():
img1 = read_img("1a")
width, height = img1.size
img1_data = pil_to_flatten_data(img1)
img2_data = list(img1_data)
ignore_mask = [0] * (width * height)
for i in range(0, width * height, 7):
ignore_mask[i] = 255
img2_data[4 * i : 4 * i + 3] = [255 - v for v in img2_data[4 * i : 4 * i + 3]]
assert pixelmatch(img1_data, img2_data, width, height, **OPTIONS) > 0

output = [0.0] * len(img1_data)
expected_output = [0.0] * len(img1_data)
pixelmatch(img1_data, img1_data, width, height, expected_output, **OPTIONS)

mismatch = pixelmatch(
img1_data,
img2_data,
width,
height,
output,
ignore_mask=ignore_mask,
**OPTIONS,
)
assert mismatch == 0
assert output == expected_output
assert (
pixelmatch(
img1_data,
img2_data,
width,
height,
fail_fast=True,
ignore_mask=ignore_mask,
**OPTIONS,
)
== 0
)


def test_pixelmatch_ignore_mask_size_mismatch():
img1_data = [0] * (4 * 20 * 20)
with pytest.raises(ValueError, match="Ignore mask size"):
pixelmatch(img1_data, img1_data, 20, 20, ignore_mask=[0] * (4 * 20 * 20))
with pytest.raises(ValueError, match="Ignore mask size"):

Check warning on line 302 in test_pixelmatch.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=whtsky_pixelmatch-py&issues=AaFlYkP9ZeDylSsDxErY&open=AaFlYkP9ZeDylSsDxErY&pullRequest=268
PIL.pixelmatch(
Image.new("RGBA", (20, 20)),
Image.new("RGBA", (20, 20)),
ignore_mask=[0] * 10,
)
with pytest.raises(ValueError, match="Ignore mask size"):

Check warning on line 308 in test_pixelmatch.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=whtsky_pixelmatch-py&issues=AaFlYkP9ZeDylSsDxErZ&open=AaFlYkP9ZeDylSsDxErZ&pullRequest=268
PIL.pixelmatch(
Image.new("RGBA", (20, 20)),
Image.new("RGBA", (20, 20)),
ignore_mask=Image.new("L", (10, 10)),
)


def test_PIL_pixelmatch_ignore_mask():
img1 = read_img("1a")
img2 = read_img("1b")
width, height = img1.size
ignore_mask = _half_ignore_mask(width, height)
expected_output = [0.0] * (4 * width * height)
expected_mismatch = pixelmatch(
pil_to_flatten_data(img1),
pil_to_flatten_data(img2),
width,
height,
expected_output,
ignore_mask=ignore_mask,
**OPTIONS,
)

mask_image = Image.new("1", img1.size)
mask_image.putdata(ignore_mask)

for mask in (ignore_mask, mask_image, mask_image.convert("L")):
output = Image.new("RGBA", img1.size)
mismatch = PIL.pixelmatch(img1, img2, output, ignore_mask=mask, **OPTIONS)
assert mismatch == expected_mismatch
assert pil_to_flatten_data(output) == expected_output