From db451906aa079d59aa0f386fa85d0851ecc38d8b Mon Sep 17 00:00:00 2001 From: anyingiit <49945850+anyingiit@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:51:09 +0800 Subject: [PATCH] feat: add ignore_mask option Port upstream pixelmatch v8's ignoreMask (mapbox/pixelmatch#171): pixels whose mask value is non-zero are skipped from the comparison and drawn as unchanged in the diff output, and differences that fall only inside the mask take the identical-images fast path. pixelmatch.core accepts one value per pixel; pixelmatch.contrib.PIL also accepts a PIL.Image of the same size, converted to grayscale. Closes #256 --- README.md | 5 +- pixelmatch/contrib/PIL.py | 22 +++++- pixelmatch/core.py | 31 +++++++-- test_pixelmatch.py | 139 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c12063b..92ae267 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/pixelmatch/contrib/PIL.py b/pixelmatch/contrib/PIL.py index 6490806..6a7eb5b 100644 --- a/pixelmatch/contrib/PIL.py +++ b/pixelmatch/contrib/PIL.py @@ -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 @@ -20,6 +20,7 @@ def pixelmatch( diff_color: RGBTuple = (255, 0, 0), diff_mask: bool = False, fail_fast: bool = False, + ignore_mask: Optional[Union[Image, Sequence[int]]] = None, ) -> int: """ Compares two images, writes the output diff and returns the number of mismatched pixels. @@ -40,9 +41,27 @@ 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: 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() @@ -73,6 +92,7 @@ def pixelmatch( 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: diff --git a/pixelmatch/core.py b/pixelmatch/core.py index 8d40adf..63def15 100644 --- a/pixelmatch/core.py +++ b/pixelmatch/core.py @@ -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 @@ -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. @@ -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 """ @@ -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) @@ -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: diff --git a/test_pixelmatch.py b/test_pixelmatch.py index d432992..4276b4a 100644 --- a/test_pixelmatch.py +++ b/test_pixelmatch.py @@ -198,3 +198,142 @@ def test_PIL_pixelmatch_identical_transparent_images_output_matches_core( 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"): + PIL.pixelmatch( + Image.new("RGBA", (20, 20)), + Image.new("RGBA", (20, 20)), + ignore_mask=[0] * 10, + ) + with pytest.raises(ValueError, match="Ignore mask size"): + 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