diff --git a/README.md b/README.md index 7c58ce5..762e60c 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,11 @@ python -m pip install pixelmatch ## API -### pixelmatch(img1, img2, width, height[output, options]) +### pixelmatch(img1, img2, width, height, output, threshold, includeAA, alpha, aa_color, diff_color, diff_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. - `output` — Image data to write the diff to, or `None` if don't need a diff image. Note that _all three images_ need to have the same dimensions. - `options` is a dict with the following properties: - - `threshold` — Matching threshold, ranges from `0` to `1`. Smaller values make the comparison more sensitive. `0.1` by default. - `includeAA` — If `true`, disables detecting and ignoring anti-aliased pixels. `false` by default. - `alpha` — Blending factor of unchanged pixels in the diff output. Ranges from `0` for pure white to `1` for original brightness. `0.1` by default. @@ -68,9 +66,7 @@ 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 -}) +mismatch = pixelmatch(data_a, data_b, width, height, data_diff, includeAA=True) img_diff = Image.new("RGBA", img_a.size) @@ -94,6 +90,7 @@ img_diff.save("diff.png") ### vnext +- ft: refactor code to be more pythonic - docs: use absolute url for images in README ### v0.1.1 diff --git a/pixelmatch.py b/pixelmatch/__init__.py similarity index 57% rename from pixelmatch.py rename to pixelmatch/__init__.py index dacc8d7..dea4d2b 100644 --- a/pixelmatch.py +++ b/pixelmatch/__init__.py @@ -1,17 +1,54 @@ -DEFAULT_OPTIONS = { - "threshold": 0.1, # matching threshold (0 to 1); smaller is more sensitive - "includeAA": False, # whether to skip anti-aliasing detection - "alpha": 0.1, # opacity of original image in diff ouput - "aa_color": [255, 255, 0], # color of anti-aliased pixels in diff output - "diff_color": [255, 0, 0], # color of different pixels in diff output - "diff_mask": False, # draw the diff over a transparent background (a mask) -} - - -def pixelmatch(img1, img2, width: int, height: int, output=None, options=None): +from typing import Union, List, Tuple, MutableSequence, Sequence, Optional + +# note: this shouldn't be necessary, but apparently is +Number = Union[int, float] +ImageSequence = Sequence[Number] +MutableImageSequence = MutableSequence[Number] +RGBTuple = Union[Tuple[Number, Number, Number], List[Number]] + + +def pixelmatch( + img1: ImageSequence, + img2: ImageSequence, + width: int, + height: int, + output: Optional[MutableImageSequence] = 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. + 'Raw image data' refers to a 1D, indexable collection of image data in the + format [R1, G1, B1, A1, R2, G2, ...]. + + :param img1: Image data to compare with img2. Must be the same size as img2 + :param img2: Image data to compare with img2. Must be the same size as img1 + :param width: Width of both images (they should be the same). + :param height: Height of both images (they should be the same). + :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 + """ - if len(img1) != len(img2) or (output and len(output) != len(img1)): - raise ValueError("Image sizes do not match.", len(img1), len(img2), len(output)) + if len(img1) != len(img2): + raise ValueError("Image sizes do not match.", len(img1), len(img2)) + if output and len(output) != len(img1): + raise ValueError( + "Diff image size does not match img1 & img2.", len(img1), len(output) + ) if len(img1) != width * height * 4: raise ValueError( @@ -20,26 +57,21 @@ def pixelmatch(img1, img2, width: int, height: int, output=None, options=None): width * height * 4, ) - if options: - options = {**DEFAULT_OPTIONS, **options} - else: - options = DEFAULT_OPTIONS - # fast path if identical if img1 == img2: - if output and not options["diff_mask"]: + if output and not diff_mask: for i in range(width * height): - draw_gray_pixel(img1, 4 * i, options["alpha"], output) + draw_gray_pixel(img1, 4 * i, alpha, output) return 0 # maximum acceptable square distance between two colors; # 35215 is the maximum possible value for the YIQ difference metric - maxDelta = 35215 * options["threshold"] * options["threshold"] + maxDelta = 35215 * threshold * threshold diff = 0 - [aaR, aaG, aaB] = options["aa_color"] - [diffR, diffG, diffB] = options["diff_color"] + aaR, aaG, aaB = aa_color + diffR, diffG, diffB = diff_color # compare each pixel of one image against the other one for y in range(height): @@ -52,13 +84,13 @@ def pixelmatch(img1, img2, width: int, height: int, output=None, options=None): # the color difference is above the threshold if delta > maxDelta: # check it's a real rendering difference or just anti-aliasing - if not options["includeAA"] and ( + if not includeAA and ( antialiased(img1, x, y, width, height, img2) or antialiased(img2, x, y, width, height, img1) ): # one of the pixels is anti-aliasing; draw as yellow and do not count as difference # note that we do not include such pixels in a mask - if output and not options["diff_mask"]: + if output and not diff_mask: draw_pixel(output, pos, aaR, aaG, aaB) else: # found substantial difference not caused by anti-aliasing; draw it as red @@ -68,14 +100,16 @@ def pixelmatch(img1, img2, width: int, height: int, output=None, options=None): elif output: # pixels are similar; draw background as grayscale image blended with white - if not options["diff_mask"]: - draw_gray_pixel(img1, pos, options["alpha"], output) + if not diff_mask: + draw_gray_pixel(img1, pos, alpha, output) # return the number of different pixels return diff -def antialiased(img, x1, y1, width, height, img2): +def antialiased( + img: ImageSequence, x1: int, y1: int, width: int, height: int, img2: ImageSequence +): """ check if a pixel is likely a part of anti-aliasing; based on "Anti-aliased Pixel and Intensity Slope Detector" paper by V. Vysniauskas, 2009 @@ -86,12 +120,7 @@ def antialiased(img, x1, y1, width, height, img2): y2 = min(y1 + 1, height - 1) pos = (y1 * width + x1) * 4 zeroes = (x1 == x0 or x1 == x2 or y1 == y0 or y1 == y2) and 1 or 0 - min_delta = 0 - max_delta = 0 - min_x = 0 - min_y = 0 - max_x = 0 - max_y = 0 + min_delta = max_delta = min_x = min_y = max_x = max_y = 0 # go through 8 adjacent pixels for x in range(x0, x2 + 1): @@ -136,7 +165,7 @@ def antialiased(img, x1, y1, width, height, img2): ) -def has_many_siblings(img, x1, y1, width, height): +def has_many_siblings(img: ImageSequence, x1: int, y1: int, width: int, height: int): """ check if a pixel has 3+ adjacent pixels of the same color. """ @@ -154,12 +183,7 @@ def has_many_siblings(img, x1, y1, width, height): continue pos2 = (y * width + x) * 4 - if ( - img[pos] == img[pos2] - and img[pos + 1] == img[pos2 + 1] - and img[pos + 2] == img[pos2 + 2] - and img[pos + 3] == img[pos2 + 3] - ): + if all(img[pos + offset] == img[pos2 + offset] for offset in range(4)): zeroes += 1 if zeroes > 2: @@ -168,36 +192,26 @@ def has_many_siblings(img, x1, y1, width, height): return False -def color_delta(img1, img2, k, m, y_only=False): +def color_delta( + img1: ImageSequence, img2: ImageSequence, k: int, m: int, y_only: bool = False +): """ calculate color difference according to the paper "Measuring perceived color difference using YIQ NTSC transmission color space in mobile applications" by Y. Kotsarenko and F. Ramos """ - - r1 = img1[k + 0] - g1 = img1[k + 1] - b1 = img1[k + 2] - a1 = img1[k + 3] - - r2 = img2[m + 0] - g2 = img2[m + 1] - b2 = img2[m + 2] - a2 = img2[m + 3] + r1, g1, b1, a1 = [img1[k + offset] for offset in range(4)] + r2, g2, b2, a2 = [img2[m + offset] for offset in range(4)] if a1 == a2 and r1 == r2 and g1 == g2 and b1 == b2: return 0 if a1 < 255: a1 /= 255 - r1 = blend(r1, a1) - g1 = blend(g1, a1) - b1 = blend(b1, a1) + r1, b1, g1 = blendRGB(r1, b1, g1, a1) if a2 < 255: a2 /= 255 - r2 = blend(r2, a2) - g2 = blend(g2, a2) - b2 = blend(b2, a2) + r2, b2, g2 = blendRGB(r2, b2, g2, a2) y = rgb2y(r1, g1, b1) - rgb2y(r2, g2, b2) @@ -211,31 +225,45 @@ def color_delta(img1, img2, k, m, y_only=False): return 0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q -def rgb2y(r: int, g: int, b: int): +def rgb2y(r: float, g: float, b: float): return r * 0.29889531 + g * 0.58662247 + b * 0.11448223 -def rgb2i(r: int, g: int, b: int): +def rgb2i(r: float, g: float, b: float): return r * 0.59597799 - g * 0.27417610 - b * 0.32180189 -def rgb2q(r: int, g: int, b: int): +def rgb2q(r: float, g: float, b: float): return r * 0.21147017 - g * 0.52261711 + b * 0.31114694 -def blend(c, a): +def blendRGB(r: float, g: float, b: float, a: float): + """ + Blend r, g, and b with a + :param r: red channel to blend with a + :param g: green channel to blend with a + :param b: blue channel to blend with a + :param a: alpha to blend with + :return: tuple of blended r, g, b + """ + return blend(r, a), blend(g, a), blend(b, a) + + +def blend(c: float, a: float): """blend semi-transparent color with white""" return 255 + (c - 255) * a -def draw_pixel(output, pos: int, r: int, g: int, b: int): +def draw_pixel(output: MutableImageSequence, pos: int, r: float, g: float, b: float): output[pos + 0] = int(r) output[pos + 1] = int(g) output[pos + 2] = int(b) output[pos + 3] = 255 -def draw_gray_pixel(img, i: int, alpha, output): +def draw_gray_pixel( + img: ImageSequence, i: int, alpha: float, output: MutableImageSequence +): r = img[i + 0] g = img[i + 1] b = img[i + 2] diff --git a/test_pixelmatch.py b/test_pixelmatch.py index a225959..86b28e6 100644 --- a/test_pixelmatch.py +++ b/test_pixelmatch.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Dict import pytest from PIL import Image @@ -55,7 +56,11 @@ def pil_to_flatten_data(img): "img_path_1,img_path_2,diff_path,options,expected_mismatch", testdata ) def test_pixelmatch( - img_path_1: str, img_path_2: str, diff_path: str, options, expected_mismatch: int + img_path_1: str, + img_path_2: str, + diff_path: str, + options: Dict, + expected_mismatch: int, ): img1 = read_img(img_path_1) @@ -63,10 +68,10 @@ def test_pixelmatch( width, height = img1.size img1_data = pil_to_flatten_data(img1) img2_data = pil_to_flatten_data(img2) - diff_data = [0] * len(img1_data) + diff_data = [0.0] * len(img1_data) - mismatch = pixelmatch(img1_data, img2_data, width, height, diff_data, options) - mismatch2 = pixelmatch(img1_data, img2_data, width, height, None, options) + mismatch = pixelmatch(img1_data, img2_data, width, height, diff_data, **options) + mismatch2 = pixelmatch(img1_data, img2_data, width, height, None, **options) expected_diff = read_img(diff_path) assert diff_data == pil_to_flatten_data(expected_diff), "diff image"