Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Major rework: more pythonic API, code rework #38

Merged
merged 26 commits into from
May 8, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
61d7830
code cleanup, new tests, more pythonic
Mattwmaster58 May 7, 2020
760047c
support for output being a PIL.Image
Mattwmaster58 May 7, 2020
f835664
update tests
Mattwmaster58 May 7, 2020
96797e9
fix pixelmatch implementation
Mattwmaster58 May 7, 2020
3e62e5f
more descriptive error messages
Mattwmaster58 May 7, 2020
96a6b40
bump version
Mattwmaster58 May 7, 2020
12e822a
blackify
Mattwmaster58 May 7, 2020
25d82e7
add docstrings
Mattwmaster58 May 7, 2020
9e4c4b4
update docs
Mattwmaster58 May 7, 2020
fd64d7a
code cleanup/refactoring, remove PIL.Image support in favor of moving…
Mattwmaster58 May 8, 2020
91ec2b5
code cleanup/refactoring, remove PIL.Image support in favor of moving…
Mattwmaster58 May 8, 2020
f9b941d
Merge remote-tracking branch 'origin/master'
Mattwmaster58 May 8, 2020
fd62dbc
remove out of scope test
Mattwmaster58 May 8, 2020
33d60a9
update docs to reflect non-support of PIL by default
Mattwmaster58 May 8, 2020
d62a2f9
update type hints
Mattwmaster58 May 8, 2020
8155bc1
blackify
Mattwmaster58 May 8, 2020
9abfc19
remove unused files
Mattwmaster58 May 8, 2020
c8978b7
fix int | float typing errors w/ mypy, pyright
Mattwmaster58 May 8, 2020
a81f78d
fix bad typing + replace missing changelog item
Mattwmaster58 May 8, 2020
d3a387d
fix bad typing + replace missing changelog item
Mattwmaster58 May 8, 2020
5c72e66
Merge remote-tracking branch 'origin/master'
Mattwmaster58 May 8, 2020
cb63e9a
Merge remote-tracking branch 'origin_upstream/master'
Mattwmaster58 May 8, 2020
42c36b4
fix mypy error
Mattwmaster58 May 8, 2020
cf3f10d
blackify
Mattwmaster58 May 8, 2020
8a9e1d4
correct changelog
Mattwmaster58 May 8, 2020
4b59c16
remove unused impport
Mattwmaster58 May 8, 2020
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
9 changes: 3 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand Down
156 changes: 92 additions & 64 deletions pixelmatch.py → pixelmatch/__init__.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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):
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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.
"""
Expand All @@ -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:
Expand All @@ -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)

Expand All @@ -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]
Expand Down
13 changes: 9 additions & 4 deletions test_pixelmatch.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from pathlib import Path
from typing import Dict

import pytest
from PIL import Image
Expand Down Expand Up @@ -55,18 +56,22 @@ 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)
img2 = read_img(img_path_2)
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"
Expand Down