OLD | NEW |
1 #!/usr/bin/python | 1 #!/usr/bin/python |
2 | 2 |
3 """ | 3 """ |
4 Copyright 2013 Google Inc. | 4 Copyright 2013 Google Inc. |
5 | 5 |
6 Use of this source code is governed by a BSD-style license that can be | 6 Use of this source code is governed by a BSD-style license that can be |
7 found in the LICENSE file. | 7 found in the LICENSE file. |
8 | 8 |
9 Calulate differences between image pairs, and store them in a database. | 9 Calulate differences between image pairs, and store them in a database. |
10 """ | 10 """ |
11 | 11 |
12 import contextlib | 12 import contextlib |
13 import logging | 13 import logging |
14 import os | 14 import os |
15 import shutil | 15 import shutil |
16 import urllib | 16 import urllib |
17 try: | 17 try: |
18 from PIL import Image, ImageChops | 18 from PIL import Image, ImageChops |
19 except ImportError: | 19 except ImportError: |
20 raise ImportError('Requires PIL to be installed; see ' | 20 raise ImportError('Requires PIL to be installed; see ' |
21 + 'http://www.pythonware.com/products/pil/') | 21 + 'http://www.pythonware.com/products/pil/') |
22 | 22 |
23 IMAGE_SUFFIX = '.png' | 23 IMAGE_SUFFIX = '.png' |
24 IMAGE_FORMAT = 'PNG' # must match one of the PIL image formats, listed at | |
25 # http://effbot.org/imagingbook/formats.htm | |
26 | 24 |
27 IMAGES_SUBDIR = 'images' | 25 IMAGES_SUBDIR = 'images' |
28 DIFFS_SUBDIR = 'diffs' | 26 DIFFS_SUBDIR = 'diffs' |
29 WHITEDIFFS_SUBDIR = 'whitediffs' | 27 WHITEDIFFS_SUBDIR = 'whitediffs' |
30 | 28 |
| 29 VALUES_PER_BAND = 256 |
| 30 |
31 | 31 |
32 class DiffRecord(object): | 32 class DiffRecord(object): |
33 """ Record of differences between two images. """ | 33 """ Record of differences between two images. """ |
34 | 34 |
35 def __init__(self, storage_root, | 35 def __init__(self, storage_root, |
36 expected_image_url, expected_image_locator, | 36 expected_image_url, expected_image_locator, |
37 actual_image_url, actual_image_locator): | 37 actual_image_url, actual_image_locator): |
38 """Download this pair of images (unless we already have them on local disk), | 38 """Download this pair of images (unless we already have them on local disk), |
39 and prepare a DiffRecord for them. | 39 and prepare a DiffRecord for them. |
40 | 40 |
(...skipping 17 matching lines...) Expand all Loading... |
58 # Download the expected/actual images, if we don't have them already. | 58 # Download the expected/actual images, if we don't have them already. |
59 expected_image = _download_and_open_image( | 59 expected_image = _download_and_open_image( |
60 os.path.join(storage_root, IMAGES_SUBDIR, | 60 os.path.join(storage_root, IMAGES_SUBDIR, |
61 str(expected_image_locator) + IMAGE_SUFFIX), | 61 str(expected_image_locator) + IMAGE_SUFFIX), |
62 expected_image_url) | 62 expected_image_url) |
63 actual_image = _download_and_open_image( | 63 actual_image = _download_and_open_image( |
64 os.path.join(storage_root, IMAGES_SUBDIR, | 64 os.path.join(storage_root, IMAGES_SUBDIR, |
65 str(actual_image_locator) + IMAGE_SUFFIX), | 65 str(actual_image_locator) + IMAGE_SUFFIX), |
66 actual_image_url) | 66 actual_image_url) |
67 | 67 |
68 # Store the diff image (absolute diff at each pixel). | 68 # Generate the diff image (absolute diff at each pixel) and |
| 69 # max_diff_per_channel. |
69 diff_image = _generate_image_diff(actual_image, expected_image) | 70 diff_image = _generate_image_diff(actual_image, expected_image) |
70 self._weighted_diff_measure = _calculate_weighted_diff_metric(diff_image) | 71 diff_histogram = diff_image.histogram() |
| 72 (diff_width, diff_height) = diff_image.size |
| 73 self._weighted_diff_measure = _calculate_weighted_diff_metric( |
| 74 diff_histogram, diff_width * diff_height) |
| 75 self._max_diff_per_channel = _max_per_band(diff_histogram) |
| 76 |
| 77 # Generate the whitediff image (any differing pixels show as white). |
| 78 # This is tricky, because when you convert color images to grayscale or |
| 79 # black & white in PIL, it has its own ideas about thresholds. |
| 80 # We have to force it: if a pixel has any color at all, it's a '1'. |
| 81 bands = diff_image.split() |
| 82 graydiff_image = ImageChops.lighter(ImageChops.lighter( |
| 83 bands[0], bands[1]), bands[2]) |
| 84 whitediff_image = (graydiff_image.point(lambda p: p > 0 and VALUES_PER_BAND) |
| 85 .convert('1', dither=Image.NONE)) |
| 86 |
| 87 # Final touches on diff_image: use whitediff_image as an alpha mask. |
| 88 # Unchanged pixels are transparent; differing pixels are opaque. |
| 89 diff_image.putalpha(whitediff_image) |
| 90 |
| 91 # Store the diff and whitediff images generated above. |
71 diff_image_locator = _get_difference_locator( | 92 diff_image_locator = _get_difference_locator( |
72 expected_image_locator=expected_image_locator, | 93 expected_image_locator=expected_image_locator, |
73 actual_image_locator=actual_image_locator) | 94 actual_image_locator=actual_image_locator) |
74 diff_image_filepath = os.path.join( | 95 basename = str(diff_image_locator) + IMAGE_SUFFIX |
75 storage_root, DIFFS_SUBDIR, str(diff_image_locator) + IMAGE_SUFFIX) | 96 _save_image(diff_image, os.path.join( |
76 _mkdir_unless_exists(os.path.join(storage_root, DIFFS_SUBDIR)) | 97 storage_root, DIFFS_SUBDIR, basename)) |
77 diff_image.save(diff_image_filepath, IMAGE_FORMAT) | 98 _save_image(whitediff_image, os.path.join( |
78 | 99 storage_root, WHITEDIFFS_SUBDIR, basename)) |
79 # Store the whitediff image (any differing pixels show as white). | |
80 # | |
81 # TODO(epoger): From http://effbot.org/imagingbook/image.htm , it seems | |
82 # like we should be able to use im.point(function, mode) to perform both | |
83 # the point() and convert('1') operations simultaneously, but I couldn't | |
84 # get it to work. | |
85 whitediff_image = (diff_image.point(lambda p: (0, 256)[p!=0]) | |
86 .convert('1')) | |
87 whitediff_image_filepath = os.path.join( | |
88 storage_root, WHITEDIFFS_SUBDIR, str(diff_image_locator) + IMAGE_SUFFIX) | |
89 _mkdir_unless_exists(os.path.join(storage_root, WHITEDIFFS_SUBDIR)) | |
90 whitediff_image.save(whitediff_image_filepath, IMAGE_FORMAT) | |
91 | 100 |
92 # Calculate difference metrics. | 101 # Calculate difference metrics. |
93 (self._width, self._height) = diff_image.size | 102 (self._width, self._height) = diff_image.size |
94 self._num_pixels_differing = whitediff_image.histogram()[255] | 103 self._num_pixels_differing = ( |
| 104 whitediff_image.histogram()[VALUES_PER_BAND - 1]) |
95 | 105 |
96 def get_num_pixels_differing(self): | 106 def get_num_pixels_differing(self): |
97 """Returns the absolute number of pixels that differ.""" | 107 """Returns the absolute number of pixels that differ.""" |
98 return self._num_pixels_differing | 108 return self._num_pixels_differing |
99 | 109 |
100 def get_percent_pixels_differing(self): | 110 def get_percent_pixels_differing(self): |
101 """Returns the percentage of pixels that differ, as a float between | 111 """Returns the percentage of pixels that differ, as a float between |
102 0 and 100 (inclusive).""" | 112 0 and 100 (inclusive).""" |
103 return ((float(self._num_pixels_differing) * 100) / | 113 return ((float(self._num_pixels_differing) * 100) / |
104 (self._width * self._height)) | 114 (self._width * self._height)) |
105 | 115 |
106 def get_weighted_diff_measure(self): | 116 def get_weighted_diff_measure(self): |
107 """Returns a weighted measure of image diffs, as a float between 0 and 100 | 117 """Returns a weighted measure of image diffs, as a float between 0 and 100 |
108 (inclusive).""" | 118 (inclusive).""" |
109 return self._weighted_diff_measure | 119 return self._weighted_diff_measure |
110 | 120 |
| 121 def get_max_diff_per_channel(self): |
| 122 """Returns the maximum difference between the expected and actual images |
| 123 for each R/G/B channel, as a list.""" |
| 124 return self._max_diff_per_channel |
| 125 |
111 | 126 |
112 class ImageDiffDB(object): | 127 class ImageDiffDB(object): |
113 """ Calculates differences between image pairs, maintaining a database of | 128 """ Calculates differences between image pairs, maintaining a database of |
114 them for download.""" | 129 them for download.""" |
115 | 130 |
116 def __init__(self, storage_root): | 131 def __init__(self, storage_root): |
117 """ | 132 """ |
118 Args: | 133 Args: |
119 storage_root: string; root path within the DB will store all of its stuff | 134 storage_root: string; root path within the DB will store all of its stuff |
120 """ | 135 """ |
(...skipping 47 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
168 """Returns the DiffRecord for this image pair. | 183 """Returns the DiffRecord for this image pair. |
169 | 184 |
170 Raises a KeyError if we don't have a DiffRecord for this image pair. | 185 Raises a KeyError if we don't have a DiffRecord for this image pair. |
171 """ | 186 """ |
172 key = (expected_image_locator, actual_image_locator) | 187 key = (expected_image_locator, actual_image_locator) |
173 return self._diff_dict[key] | 188 return self._diff_dict[key] |
174 | 189 |
175 | 190 |
176 # Utility functions | 191 # Utility functions |
177 | 192 |
178 def _calculate_weighted_diff_metric(image): | 193 def _calculate_weighted_diff_metric(histogram, num_pixels): |
179 """Given a diff image (per-channel diff at each pixel between two images), | 194 """Given the histogram of a diff image (per-channel diff at each |
180 calculate the weighted diff metric (a stab at how different the two images | 195 pixel between two images), calculate the weighted diff metric (a |
181 really are). | 196 stab at how different the two images really are). |
182 | 197 |
183 Args: | 198 Args: |
184 image: PIL image; a per-channel diff between two images | 199 histogram: PIL histogram of a per-channel diff between two images |
| 200 num_pixels: integer; the total number of pixels in the diff image |
185 | 201 |
186 Returns: a weighted diff metric, as a float between 0 and 100 (inclusive). | 202 Returns: a weighted diff metric, as a float between 0 and 100 (inclusive). |
187 """ | 203 """ |
188 # TODO(epoger): This is just a wild guess at an appropriate metric. | 204 # TODO(epoger): As a wild guess at an appropriate metric, weight each |
| 205 # different pixel by the square of its delta value. (The more different |
| 206 # a pixel is from its expectation, the more we care about it.) |
189 # In the long term, we will probably use some metric generated by | 207 # In the long term, we will probably use some metric generated by |
190 # skpdiff anyway. | 208 # skpdiff anyway. |
191 (width, height) = image.size | 209 assert(len(histogram) % VALUES_PER_BAND == 0) |
192 maxdiff = 3 * (width * height) * 255**2 | 210 num_bands = len(histogram) / VALUES_PER_BAND |
193 h = image.histogram() | 211 max_diff = num_pixels * num_bands * (VALUES_PER_BAND - 1)**2 |
194 assert(len(h) % 256 == 0) | 212 total_diff = 0 |
195 totaldiff = sum(map(lambda index,value: value * (index%256)**2, | 213 for index in xrange(len(histogram)): |
196 range(len(h)), h)) | 214 total_diff += histogram[index] * (index % VALUES_PER_BAND)**2 |
197 return float(100 * totaldiff) / maxdiff | 215 return float(100 * total_diff) / max_diff |
| 216 |
| 217 def _max_per_band(histogram): |
| 218 """Given the histogram of an image, return the maximum value of each band |
| 219 (a.k.a. "color channel", such as R/G/B) across the entire image. |
| 220 |
| 221 Args: |
| 222 histogram: PIL histogram |
| 223 |
| 224 Returns the maximum value of each band within the image histogram, as a list. |
| 225 """ |
| 226 max_per_band = [] |
| 227 assert(len(histogram) % VALUES_PER_BAND == 0) |
| 228 num_bands = len(histogram) / VALUES_PER_BAND |
| 229 for band in xrange(num_bands): |
| 230 # Assuming that VALUES_PER_BAND is 256... |
| 231 # the 'R' band makes up indices 0-255 in the histogram, |
| 232 # the 'G' band makes up indices 256-511 in the histogram, |
| 233 # etc. |
| 234 min_index = band * VALUES_PER_BAND |
| 235 index = min_index + VALUES_PER_BAND |
| 236 while index > min_index: |
| 237 index -= 1 |
| 238 if histogram[index] > 0: |
| 239 max_per_band.append(index - min_index) |
| 240 break |
| 241 return max_per_band |
198 | 242 |
199 def _generate_image_diff(image1, image2): | 243 def _generate_image_diff(image1, image2): |
200 """Wrapper for ImageChops.difference(image1, image2) that will handle some | 244 """Wrapper for ImageChops.difference(image1, image2) that will handle some |
201 errors automatically, or at least yield more useful error messages. | 245 errors automatically, or at least yield more useful error messages. |
202 | 246 |
203 TODO(epoger): Currently, some of the images generated by the bots are RGBA | 247 TODO(epoger): Currently, some of the images generated by the bots are RGBA |
204 and others are RGB. I'm not sure why that is. For now, to avoid confusion | 248 and others are RGB. I'm not sure why that is. For now, to avoid confusion |
205 within the UI, convert all to RGB when diffing. | 249 within the UI, convert all to RGB when diffing. |
206 | 250 |
207 Args: | 251 Args: |
(...skipping 33 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
241 filepath: path on local disk to load image from | 285 filepath: path on local disk to load image from |
242 | 286 |
243 Returns: a PIL image object | 287 Returns: a PIL image object |
244 """ | 288 """ |
245 try: | 289 try: |
246 return Image.open(filepath) | 290 return Image.open(filepath) |
247 except IOError: | 291 except IOError: |
248 logging.error('IOError loading image file %s' % filepath) | 292 logging.error('IOError loading image file %s' % filepath) |
249 raise | 293 raise |
250 | 294 |
| 295 def _save_image(image, filepath, format='PNG'): |
| 296 """Write an image to disk, creating any intermediate directories as needed. |
| 297 |
| 298 Args: |
| 299 image: a PIL image object |
| 300 filepath: path on local disk to write image to |
| 301 format: one of the PIL image formats, listed at |
| 302 http://effbot.org/imagingbook/formats.htm |
| 303 """ |
| 304 _mkdir_unless_exists(os.path.dirname(filepath)) |
| 305 image.save(filepath, format) |
| 306 |
251 def _mkdir_unless_exists(path): | 307 def _mkdir_unless_exists(path): |
252 """Unless path refers to an already-existing directory, create it. | 308 """Unless path refers to an already-existing directory, create it. |
253 | 309 |
254 Args: | 310 Args: |
255 path: path on local disk | 311 path: path on local disk |
256 """ | 312 """ |
257 if not os.path.isdir(path): | 313 if not os.path.isdir(path): |
258 os.makedirs(path) | 314 os.makedirs(path) |
259 | 315 |
260 def _get_difference_locator(expected_image_locator, actual_image_locator): | 316 def _get_difference_locator(expected_image_locator, actual_image_locator): |
261 """Returns the locator string used to look up the diffs between expected_image | 317 """Returns the locator string used to look up the diffs between expected_image |
262 and actual_image. | 318 and actual_image. |
263 | 319 |
264 Args: | 320 Args: |
265 expected_image_locator: locator string pointing at expected image | 321 expected_image_locator: locator string pointing at expected image |
266 actual_image_locator: locator string pointing at actual image | 322 actual_image_locator: locator string pointing at actual image |
267 | 323 |
268 Returns: locator where the diffs between expected and actual images can be | 324 Returns: locator where the diffs between expected and actual images can be |
269 found | 325 found |
270 """ | 326 """ |
271 return "%s-vs-%s" % (expected_image_locator, actual_image_locator) | 327 return "%s-vs-%s" % (expected_image_locator, actual_image_locator) |
OLD | NEW |