├── diffimg ├── __init__.py ├── __main__.py ├── diff.py └── test.py ├── setup.cfg ├── images ├── black.png ├── white.png ├── diff_img.jpg ├── diff_img.png ├── yandex1.png ├── yandex2.png ├── mario-circle-cs.png └── mario-circle-node.png ├── setup.py ├── test_install.sh ├── LICENSE.txt └── README.md /diffimg/__init__.py: -------------------------------------------------------------------------------- 1 | from .diff import diff 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /images/black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicolashahn/diffimg/HEAD/images/black.png -------------------------------------------------------------------------------- /images/white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicolashahn/diffimg/HEAD/images/white.png -------------------------------------------------------------------------------- /images/diff_img.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicolashahn/diffimg/HEAD/images/diff_img.jpg -------------------------------------------------------------------------------- /images/diff_img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicolashahn/diffimg/HEAD/images/diff_img.png -------------------------------------------------------------------------------- /images/yandex1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicolashahn/diffimg/HEAD/images/yandex1.png -------------------------------------------------------------------------------- /images/yandex2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicolashahn/diffimg/HEAD/images/yandex2.png -------------------------------------------------------------------------------- /images/mario-circle-cs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicolashahn/diffimg/HEAD/images/mario-circle-cs.png -------------------------------------------------------------------------------- /images/mario-circle-node.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicolashahn/diffimg/HEAD/images/mario-circle-node.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | try: 2 | from setuptools import setup 3 | except ImportError: 4 | from distutils.core import setup 5 | VERSION = "0.3.0" 6 | setup( 7 | name="diffimg", 8 | packages=["diffimg"], # this must be the same as the name above 9 | version=VERSION, 10 | description="Get the % difference in images + generate a diff image", 11 | author="Nicolas Hahn", 12 | author_email="nicolas@stonespring.org", 13 | url="https://github.com/nicolashahn/python-image-diff", 14 | download_url="https://github.com/nicolashahn/python-image-diff/archive/v{}.tar.gz".format( 15 | VERSION 16 | ), 17 | keywords=["diff", "difference", "image", "test", "testing"], 18 | classifiers=[], 19 | install_requires=["Pillow>=4.3"], 20 | ) 21 | -------------------------------------------------------------------------------- /test_install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Test if the installation via setup.py works correctly 4 | 5 | if [ "$#" -ne 1 ]; then 6 | echo "Usage: test_install.sh (v = python version (2, 3))" 7 | exit 1 8 | fi 9 | 10 | VERSION=$1 11 | 12 | if [ $VERSION == 2 ]; then 13 | VENV=virtualenv 14 | else 15 | VENV=venv 16 | fi 17 | 18 | rm -rf venv 19 | python$VERSION -m $VENV venv 20 | source venv/bin/activate 21 | python$VERSION setup.py install 22 | # if we run `python -m diffimg` inside the current directory, it doesn't matter 23 | # if we've installed it or not because it will use the local dir before the 24 | # installed module 25 | cd .. 26 | python$VERSION -m \ 27 | diffimg diffimg/images/mario-circle-cs.png \ 28 | diffimg/images/mario-circle-node.png --delete 29 | 30 | if [[ $? != 0 ]]; then 31 | echo -e "\nError installing" 32 | exit 1 33 | else 34 | echo -e "\nInstall successful" 35 | exit 0 36 | fi 37 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2017 Nicolas Hahn 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /diffimg/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import print_function 4 | import argparse 5 | import sys 6 | from .diff import diff 7 | 8 | 9 | def get_args(): 10 | parser = argparse.ArgumentParser( 11 | prog="diffimg", 12 | description="Generate a diff image from two images \ 13 | and/or find difference percentage", 14 | ) 15 | parser.add_argument("image1", type=str, help="first image") 16 | parser.add_argument("image2", type=str, help="second image") 17 | parser.add_argument( 18 | "--ratio", 19 | "-r", 20 | dest="use_ratio", 21 | action="store_true", 22 | help="return a ratio instead of percentage", 23 | ) 24 | parser.add_argument( 25 | "--delete", 26 | "-d", 27 | dest="delete_diff_file", 28 | action="store_true", 29 | help="delete diff image file", 30 | ) 31 | parser.add_argument( 32 | "--filename", 33 | "-f", 34 | dest="diff_img_file", 35 | type=str, 36 | default=None, 37 | help="filename with valid extension to store diff image \ 38 | (defaults to diff_img.jpg)", 39 | ) 40 | parser.add_argument( 41 | "--ignore-alpha", 42 | "-ia", 43 | dest="ignore_alpha", 44 | action="store_true", 45 | help="ignore the alpha channel for ratio calculation \ 46 | and diff img creation", 47 | ) 48 | return parser.parse_args() 49 | 50 | 51 | def main(): 52 | args = get_args() 53 | diff_ratio = diff( 54 | args.image1, 55 | args.image2, 56 | delete_diff_file=args.delete_diff_file, 57 | diff_img_file=args.diff_img_file, 58 | ignore_alpha=args.ignore_alpha, 59 | ) 60 | if args.use_ratio: 61 | print(diff_ratio) 62 | else: 63 | print("Images differ by {}%".format(diff_ratio * 100)) 64 | return 0 65 | 66 | 67 | if __name__ == "__main__": 68 | sys.exit(main()) 69 | -------------------------------------------------------------------------------- /diffimg/diff.py: -------------------------------------------------------------------------------- 1 | """ 2 | Return the % difference of two given images. 3 | Only works with images of the same file type and color channels. 4 | """ 5 | 6 | from __future__ import print_function 7 | from PIL import Image, ImageChops, ImageStat 8 | 9 | DIFF_IMG_FILE = "diff_img.png" 10 | 11 | 12 | def diff( 13 | im1_file, im2_file, delete_diff_file=False, diff_img_file=None, ignore_alpha=False 14 | ): 15 | """ 16 | Calculate the difference between two images by comparing channel values at the pixel 17 | level. If the images are different sizes, the second will be resized to match the 18 | first. 19 | 20 | `delete_diff_file`: removes the diff image after ratio found 21 | `diff_img_file`: filename to store diff image 22 | `ignore_alpha`: ignore the alpha channel for ratio calculation, and set the diff 23 | image's alpha to fully opaque 24 | """ 25 | if not diff_img_file: 26 | diff_img_file = DIFF_IMG_FILE 27 | 28 | im1 = Image.open(im1_file) 29 | im2 = Image.open(im2_file) 30 | 31 | # Ensure we have the same color channels (RGBA vs RGB) 32 | if im1.mode != im2.mode: 33 | raise ValueError( 34 | ( 35 | "Differing color modes:\n {}: {}\n {}: {}\n" 36 | "Ensure image color modes are the same." 37 | ).format(im1_file, im1.mode, im2_file, im2.mode) 38 | ) 39 | 40 | # Coerce 2nd dimensions to same as 1st 41 | im2 = im2.resize((im1.width, im1.height)) 42 | 43 | # Generate diff image in memory. 44 | diff_img = ImageChops.difference(im1, im2) 45 | 46 | if ignore_alpha: 47 | diff_img.putalpha(256) 48 | 49 | if not delete_diff_file: 50 | if "." not in diff_img_file: 51 | extension = "png" 52 | else: 53 | extension = diff_img_file.split(".")[-1] 54 | if extension in ("jpg", "jpeg"): 55 | # For some reason, save() thinks "jpg" is invalid 56 | # This doesn't affect the image's saved filename 57 | extension = "jpeg" 58 | diff_img = diff_img.convert("RGB") 59 | diff_img.save(diff_img_file, extension) 60 | 61 | # Calculate difference as a ratio. 62 | stat = ImageStat.Stat(diff_img) 63 | # stat.mean can be [r,g,b] or [r,g,b,a]. 64 | removed_channels = 1 if ignore_alpha and len(stat.mean) == 4 else 0 65 | num_channels = len(stat.mean) - removed_channels 66 | sum_channel_values = sum(stat.mean[:num_channels]) 67 | max_all_channels = num_channels * 255 68 | diff_ratio = sum_channel_values / max_all_channels 69 | 70 | return diff_ratio 71 | -------------------------------------------------------------------------------- /diffimg/test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import unittest 5 | 6 | import PIL 7 | 8 | from diff import diff 9 | 10 | IMAGES_DIR = "images" 11 | 12 | 13 | def mk_img_path(f): 14 | return os.path.join(IMAGES_DIR, f) 15 | 16 | 17 | IMG1 = mk_img_path("mario-circle-cs.png") 18 | IMG2 = mk_img_path("mario-circle-node.png") 19 | IMG3 = mk_img_path("yandex1.png") # RGBA 20 | IMG4 = mk_img_path("yandex2.png") # RGB 21 | TEST_JPG_OUT = mk_img_path("test-diff.jpg") 22 | TEST_PNG_OUT = mk_img_path("test-diff.png") 23 | TEST_NO_EXT_OUT = mk_img_path("test-diff") 24 | WHITE_IMG = mk_img_path("white.png") 25 | BLACK_IMG = mk_img_path("black.png") 26 | 27 | 28 | class TestAll(unittest.TestCase): 29 | def test_mario_ratio(self): 30 | ratio = diff(IMG1, IMG2, delete_diff_file=True) 31 | self.assertEqual(ratio, 0.007319618135968298) 32 | 33 | def test_bw_image_ratio(self): 34 | ratio = diff(BLACK_IMG, WHITE_IMG, delete_diff_file=True) 35 | self.assertEqual(ratio, 1.0) 36 | 37 | def test_delete_diff_img(self): 38 | if os.path.exists(TEST_JPG_OUT): 39 | os.remove(TEST_JPG_OUT) 40 | diff(IMG1, IMG2, delete_diff_file=True, diff_img_file=TEST_JPG_OUT) 41 | self.assertFalse(os.path.exists(TEST_JPG_OUT)) 42 | 43 | def test_make_jpg_diff_img(self): 44 | diff(IMG1, IMG2, diff_img_file=TEST_JPG_OUT) 45 | self.assertTrue(os.path.exists(TEST_JPG_OUT)) 46 | os.remove(TEST_JPG_OUT) 47 | 48 | def test_different_modes(self): 49 | with self.assertRaises(ValueError): 50 | diff(IMG3, IMG4) 51 | 52 | def test_make_png_diff_img(self): 53 | # these images are the same, but it shouldn't matter for this test 54 | diff(IMG4, IMG4, diff_img_file=TEST_PNG_OUT) 55 | self.assertTrue(os.path.exists(TEST_PNG_OUT)) 56 | os.remove(TEST_PNG_OUT) 57 | 58 | def test_diff_filename_without_extension_saves_as_png(self): 59 | diff(IMG4, IMG4, diff_img_file=TEST_NO_EXT_OUT) 60 | im = PIL.Image.open(TEST_NO_EXT_OUT) 61 | self.assertEqual(im.format, "PNG") 62 | os.remove(TEST_NO_EXT_OUT) 63 | 64 | def test_ignore_alpha_mario(self): 65 | ratio = diff(IMG1, IMG2, delete_diff_file=True, ignore_alpha=True) 66 | self.assertEqual(ratio, 0.008675405566134298) 67 | 68 | def test_ignore_alpha_bw(self): 69 | ratio = diff(BLACK_IMG, WHITE_IMG, delete_diff_file=True, ignore_alpha=True) 70 | self.assertEqual(ratio, 1.0) 71 | 72 | def test_ignore_alpha_mario_file(self): 73 | diff(IMG1, IMG2, diff_img_file=TEST_PNG_OUT, ignore_alpha=True) 74 | im = PIL.Image.open(TEST_PNG_OUT) 75 | self.assertEqual(im.getextrema()[3], (255, 255)) 76 | 77 | def test_different_image_sizes(self): 78 | ratio = diff(IMG1, IMG3, delete_diff_file=True, ignore_alpha=True) 79 | # this is the ratio for the (manually inspected) resulting diff image 80 | self.assertEqual(ratio, 0.3503192421617232) 81 | 82 | 83 | if __name__ == "__main__": 84 | unittest.main() 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # diffimg 2 | Get the % difference in images using PIL's histogram + generate a diff image. Images 3 | should have the same color channels (for example, RGB vs RGBA). If the image dimensions 4 | differ, the 2nd image will be resized to match the first before calculating the diff. 5 | 6 | [![PyPI version](https://badge.fury.io/py/diffimg.svg)](https://badge.fury.io/py/diffimg) 7 | 8 | 9 | ### Installation 10 | 11 | Now available from PyPi: `pip install diffimg` 12 | 13 | ### Usage 14 | 15 | ``` 16 | >>> from diffimg import diff 17 | >>> diff('mario-circle-cs.png', 'mario-circle-node.png') 18 | 0.007319618135968298 19 | ``` 20 | The [very simple](/diffimg/diff.py#L12) `diff` function returns a raw ratio instead of a 21 | % by default. 22 | 23 | ``` 24 | diff(im1_file, 25 | im2_file, 26 | delete_diff_file=False, 27 | diff_img_file=DIFF_IMG_FILE 28 | ignore_alpha=False) 29 | ``` 30 | `im1_file, im2_file`: filenames of images to diff. 31 | 32 | `delete_diff_file`: a file showing the differing areas of the two images is generated in 33 | order to measure the diff ratio with the same dimensions as the first image. Setting 34 | this to `True` removes it after calculating the ratio. 35 | 36 | `diff_img_file`: filename for the diff image file. Defaults to `diff_img.png` 37 | (regardless of inputed file's types). 38 | 39 | `ignore_alpha`: ignore the alpha channel for the ratio and if applicable, sets the alpha 40 | of the diff image to fully opaque. 41 | 42 | ### As command line tool 43 | 44 | `python -m diffimg image1 image2 [-r/--ratio] [-d/--delete] [-f/--filename DIFF_IMG_FILE]` 45 | 46 | `--ratio` outputs a number between 0 and 1 instead of the default `Images differ by X%`. 47 | 48 | `--delete` removes the diff file after retrieving ratio/percentage. 49 | 50 | `--filename` specifies a filename to save the diff image under. Must use a valid extension. 51 | 52 | `--ignore-alpha` ignore the alpha channel. 53 | 54 | ### Tests 55 | 56 | ``` 57 | $ ./test.py 58 | ...... 59 | ---------------------------------------------------------------------- 60 | Ran 6 tests in 0.320s 61 | 62 | OK 63 | ``` 64 | 65 | ### Formula 66 | 67 | The difference is defined by the average % difference between each of the channels 68 | (R,G,B,A?) at each pair of pixels Axy, Bxy at the same coordinates 69 | in each of the two images (why they need to be the same size), averaged over all pairs 70 | of pixels. 71 | 72 | For example, compare two 1x1 images _A_ and _B_ (a trivial example, >1 pixels would have 73 | another step to find the average of all pixels): 74 | 75 | _A_1,1 = RGB(255,0,0) _(pure red)_ 76 | 77 | _B_1,1 = RGB(100,0,0) _(dark red)_ 78 | 79 | ((255-100)/255 + (0/0)/255 + (0/0)/255))/3 = (155/255)/3 = 0.202614379 80 | 81 | So these two 1x1 images differ by __20.2614379%__ according to this formula. 82 | 83 | ## Sample image 1 84 | ![Alt text](/images/mario-circle-cs.png "Image 1") 85 | 86 | ## Sample image 2 87 | ![Alt text](/images/mario-circle-node.png "Image 2") 88 | 89 | ## Resulting diff image 90 | ![Alt text](/images/diff_img.png "Difference Image") 91 | 92 | ## Difference percentage output 93 | `Images differ by 0.731961813597%` 94 | --------------------------------------------------------------------------------