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 """ |
(...skipping 25 matching lines...) Expand all Loading... |
36 DEFAULT_IMAGE_SUFFIX = '.png' | 36 DEFAULT_IMAGE_SUFFIX = '.png' |
37 DEFAULT_IMAGES_SUBDIR = 'images' | 37 DEFAULT_IMAGES_SUBDIR = 'images' |
38 | 38 |
39 DISALLOWED_FILEPATH_CHAR_REGEX = re.compile('[^\w\-]') | 39 DISALLOWED_FILEPATH_CHAR_REGEX = re.compile('[^\w\-]') |
40 | 40 |
41 DIFFS_SUBDIR = 'diffs' | 41 DIFFS_SUBDIR = 'diffs' |
42 WHITEDIFFS_SUBDIR = 'whitediffs' | 42 WHITEDIFFS_SUBDIR = 'whitediffs' |
43 | 43 |
44 VALUES_PER_BAND = 256 | 44 VALUES_PER_BAND = 256 |
45 | 45 |
| 46 # Keys used within DiffRecord dictionary representations. |
| 47 # NOTE: Keep these in sync with static/constants.js |
| 48 KEY__DIFFERENCE_DATA__MAX_DIFF_PER_CHANNEL = 'maxDiffPerChannel' |
| 49 KEY__DIFFERENCE_DATA__NUM_DIFF_PIXELS = 'numDifferingPixels' |
| 50 KEY__DIFFERENCE_DATA__PERCENT_DIFF_PIXELS = 'percentDifferingPixels' |
| 51 KEY__DIFFERENCE_DATA__PERCEPTUAL_DIFF = 'perceptualDifference' |
| 52 KEY__DIFFERENCE_DATA__WEIGHTED_DIFF = 'weightedDiffMeasure' |
| 53 |
46 | 54 |
47 class DiffRecord(object): | 55 class DiffRecord(object): |
48 """ Record of differences between two images. """ | 56 """ Record of differences between two images. """ |
49 | 57 |
50 def __init__(self, storage_root, | 58 def __init__(self, storage_root, |
51 expected_image_url, expected_image_locator, | 59 expected_image_url, expected_image_locator, |
52 actual_image_url, actual_image_locator, | 60 actual_image_url, actual_image_locator, |
53 expected_images_subdir=DEFAULT_IMAGES_SUBDIR, | 61 expected_images_subdir=DEFAULT_IMAGES_SUBDIR, |
54 actual_images_subdir=DEFAULT_IMAGES_SUBDIR, | 62 actual_images_subdir=DEFAULT_IMAGES_SUBDIR, |
55 image_suffix=DEFAULT_IMAGE_SUFFIX): | 63 image_suffix=DEFAULT_IMAGE_SUFFIX): |
(...skipping 123 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
179 | 187 |
180 def get_max_diff_per_channel(self): | 188 def get_max_diff_per_channel(self): |
181 """Returns the maximum difference between the expected and actual images | 189 """Returns the maximum difference between the expected and actual images |
182 for each R/G/B channel, as a list.""" | 190 for each R/G/B channel, as a list.""" |
183 return self._max_diff_per_channel | 191 return self._max_diff_per_channel |
184 | 192 |
185 def as_dict(self): | 193 def as_dict(self): |
186 """Returns a dictionary representation of this DiffRecord, as needed when | 194 """Returns a dictionary representation of this DiffRecord, as needed when |
187 constructing the JSON representation.""" | 195 constructing the JSON representation.""" |
188 return { | 196 return { |
189 'numDifferingPixels': self._num_pixels_differing, | 197 KEY__DIFFERENCE_DATA__NUM_DIFF_PIXELS: self._num_pixels_differing, |
190 'percentDifferingPixels': self.get_percent_pixels_differing(), | 198 KEY__DIFFERENCE_DATA__PERCENT_DIFF_PIXELS: |
191 'weightedDiffMeasure': self.get_weighted_diff_measure(), | 199 self.get_percent_pixels_differing(), |
192 'maxDiffPerChannel': self._max_diff_per_channel, | 200 KEY__DIFFERENCE_DATA__WEIGHTED_DIFF: self.get_weighted_diff_measure(), |
| 201 KEY__DIFFERENCE_DATA__MAX_DIFF_PER_CHANNEL: self._max_diff_per_channel, |
| 202 KEY__DIFFERENCE_DATA__PERCEPTUAL_DIFF: self._perceptual_difference, |
193 } | 203 } |
194 | 204 |
195 | 205 |
196 class ImageDiffDB(object): | 206 class ImageDiffDB(object): |
197 """ Calculates differences between image pairs, maintaining a database of | 207 """ Calculates differences between image pairs, maintaining a database of |
198 them for download.""" | 208 them for download.""" |
199 | 209 |
200 def __init__(self, storage_root): | 210 def __init__(self, storage_root): |
201 """ | 211 """ |
202 Args: | 212 Args: |
(...skipping 76 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
279 # different pixel by the square of its delta value. (The more different | 289 # different pixel by the square of its delta value. (The more different |
280 # a pixel is from its expectation, the more we care about it.) | 290 # a pixel is from its expectation, the more we care about it.) |
281 assert(len(histogram) % VALUES_PER_BAND == 0) | 291 assert(len(histogram) % VALUES_PER_BAND == 0) |
282 num_bands = len(histogram) / VALUES_PER_BAND | 292 num_bands = len(histogram) / VALUES_PER_BAND |
283 max_diff = num_pixels * num_bands * (VALUES_PER_BAND - 1)**2 | 293 max_diff = num_pixels * num_bands * (VALUES_PER_BAND - 1)**2 |
284 total_diff = 0 | 294 total_diff = 0 |
285 for index in xrange(len(histogram)): | 295 for index in xrange(len(histogram)): |
286 total_diff += histogram[index] * (index % VALUES_PER_BAND)**2 | 296 total_diff += histogram[index] * (index % VALUES_PER_BAND)**2 |
287 return float(100 * total_diff) / max_diff | 297 return float(100 * total_diff) / max_diff |
288 | 298 |
| 299 |
289 def _max_per_band(histogram): | 300 def _max_per_band(histogram): |
290 """Given the histogram of an image, return the maximum value of each band | 301 """Given the histogram of an image, return the maximum value of each band |
291 (a.k.a. "color channel", such as R/G/B) across the entire image. | 302 (a.k.a. "color channel", such as R/G/B) across the entire image. |
292 | 303 |
293 Args: | 304 Args: |
294 histogram: PIL histogram | 305 histogram: PIL histogram |
295 | 306 |
296 Returns the maximum value of each band within the image histogram, as a list. | 307 Returns the maximum value of each band within the image histogram, as a list. |
297 """ | 308 """ |
298 max_per_band = [] | 309 max_per_band = [] |
299 assert(len(histogram) % VALUES_PER_BAND == 0) | 310 assert(len(histogram) % VALUES_PER_BAND == 0) |
300 num_bands = len(histogram) / VALUES_PER_BAND | 311 num_bands = len(histogram) / VALUES_PER_BAND |
301 for band in xrange(num_bands): | 312 for band in xrange(num_bands): |
302 # Assuming that VALUES_PER_BAND is 256... | 313 # Assuming that VALUES_PER_BAND is 256... |
303 # the 'R' band makes up indices 0-255 in the histogram, | 314 # the 'R' band makes up indices 0-255 in the histogram, |
304 # the 'G' band makes up indices 256-511 in the histogram, | 315 # the 'G' band makes up indices 256-511 in the histogram, |
305 # etc. | 316 # etc. |
306 min_index = band * VALUES_PER_BAND | 317 min_index = band * VALUES_PER_BAND |
307 index = min_index + VALUES_PER_BAND | 318 index = min_index + VALUES_PER_BAND |
308 while index > min_index: | 319 while index > min_index: |
309 index -= 1 | 320 index -= 1 |
310 if histogram[index] > 0: | 321 if histogram[index] > 0: |
311 max_per_band.append(index - min_index) | 322 max_per_band.append(index - min_index) |
312 break | 323 break |
313 return max_per_band | 324 return max_per_band |
314 | 325 |
| 326 |
315 def _generate_image_diff(image1, image2): | 327 def _generate_image_diff(image1, image2): |
316 """Wrapper for ImageChops.difference(image1, image2) that will handle some | 328 """Wrapper for ImageChops.difference(image1, image2) that will handle some |
317 errors automatically, or at least yield more useful error messages. | 329 errors automatically, or at least yield more useful error messages. |
318 | 330 |
319 TODO(epoger): Currently, some of the images generated by the bots are RGBA | 331 TODO(epoger): Currently, some of the images generated by the bots are RGBA |
320 and others are RGB. I'm not sure why that is. For now, to avoid confusion | 332 and others are RGB. I'm not sure why that is. For now, to avoid confusion |
321 within the UI, convert all to RGB when diffing. | 333 within the UI, convert all to RGB when diffing. |
322 | 334 |
323 Args: | 335 Args: |
324 image1: a PIL image object | 336 image1: a PIL image object |
325 image2: a PIL image object | 337 image2: a PIL image object |
326 | 338 |
327 Returns: per-pixel diffs between image1 and image2, as a PIL image object | 339 Returns: per-pixel diffs between image1 and image2, as a PIL image object |
328 """ | 340 """ |
329 try: | 341 try: |
330 return ImageChops.difference(image1.convert('RGB'), image2.convert('RGB')) | 342 return ImageChops.difference(image1.convert('RGB'), image2.convert('RGB')) |
331 except ValueError: | 343 except ValueError: |
332 logging.error('Error diffing image1 [%s] and image2 [%s].' % ( | 344 logging.error('Error diffing image1 [%s] and image2 [%s].' % ( |
333 repr(image1), repr(image2))) | 345 repr(image1), repr(image2))) |
334 raise | 346 raise |
335 | 347 |
| 348 |
336 def _download_and_open_image(local_filepath, url): | 349 def _download_and_open_image(local_filepath, url): |
337 """Open the image at local_filepath; if there is no file at that path, | 350 """Open the image at local_filepath; if there is no file at that path, |
338 download it from url to that path and then open it. | 351 download it from url to that path and then open it. |
339 | 352 |
340 Args: | 353 Args: |
341 local_filepath: path on local disk where the image should be stored | 354 local_filepath: path on local disk where the image should be stored |
342 url: URL from which we can download the image if we don't have it yet | 355 url: URL from which we can download the image if we don't have it yet |
343 | 356 |
344 Returns: a PIL image object | 357 Returns: a PIL image object |
345 """ | 358 """ |
346 if not os.path.exists(local_filepath): | 359 if not os.path.exists(local_filepath): |
347 _mkdir_unless_exists(os.path.dirname(local_filepath)) | 360 _mkdir_unless_exists(os.path.dirname(local_filepath)) |
348 with contextlib.closing(urllib.urlopen(url)) as url_handle: | 361 with contextlib.closing(urllib.urlopen(url)) as url_handle: |
349 with open(local_filepath, 'wb') as file_handle: | 362 with open(local_filepath, 'wb') as file_handle: |
350 shutil.copyfileobj(fsrc=url_handle, fdst=file_handle) | 363 shutil.copyfileobj(fsrc=url_handle, fdst=file_handle) |
351 return _open_image(local_filepath) | 364 return _open_image(local_filepath) |
352 | 365 |
| 366 |
353 def _open_image(filepath): | 367 def _open_image(filepath): |
354 """Wrapper for Image.open(filepath) that yields more useful error messages. | 368 """Wrapper for Image.open(filepath) that yields more useful error messages. |
355 | 369 |
356 Args: | 370 Args: |
357 filepath: path on local disk to load image from | 371 filepath: path on local disk to load image from |
358 | 372 |
359 Returns: a PIL image object | 373 Returns: a PIL image object |
360 """ | 374 """ |
361 try: | 375 try: |
362 return Image.open(filepath) | 376 return Image.open(filepath) |
363 except IOError: | 377 except IOError: |
364 logging.error('IOError loading image file %s' % filepath) | 378 logging.error('IOError loading image file %s' % filepath) |
365 raise | 379 raise |
366 | 380 |
| 381 |
367 def _save_image(image, filepath, format='PNG'): | 382 def _save_image(image, filepath, format='PNG'): |
368 """Write an image to disk, creating any intermediate directories as needed. | 383 """Write an image to disk, creating any intermediate directories as needed. |
369 | 384 |
370 Args: | 385 Args: |
371 image: a PIL image object | 386 image: a PIL image object |
372 filepath: path on local disk to write image to | 387 filepath: path on local disk to write image to |
373 format: one of the PIL image formats, listed at | 388 format: one of the PIL image formats, listed at |
374 http://effbot.org/imagingbook/formats.htm | 389 http://effbot.org/imagingbook/formats.htm |
375 """ | 390 """ |
376 _mkdir_unless_exists(os.path.dirname(filepath)) | 391 _mkdir_unless_exists(os.path.dirname(filepath)) |
377 image.save(filepath, format) | 392 image.save(filepath, format) |
378 | 393 |
| 394 |
379 def _mkdir_unless_exists(path): | 395 def _mkdir_unless_exists(path): |
380 """Unless path refers to an already-existing directory, create it. | 396 """Unless path refers to an already-existing directory, create it. |
381 | 397 |
382 Args: | 398 Args: |
383 path: path on local disk | 399 path: path on local disk |
384 """ | 400 """ |
385 if not os.path.isdir(path): | 401 if not os.path.isdir(path): |
386 os.makedirs(path) | 402 os.makedirs(path) |
387 | 403 |
| 404 |
388 def _sanitize_locator(locator): | 405 def _sanitize_locator(locator): |
389 """Returns a sanitized version of a locator (one in which we know none of the | 406 """Returns a sanitized version of a locator (one in which we know none of the |
390 characters will have special meaning in filenames). | 407 characters will have special meaning in filenames). |
391 | 408 |
392 Args: | 409 Args: |
393 locator: string, or something that can be represented as a string | 410 locator: string, or something that can be represented as a string |
394 """ | 411 """ |
395 return DISALLOWED_FILEPATH_CHAR_REGEX.sub('_', str(locator)) | 412 return DISALLOWED_FILEPATH_CHAR_REGEX.sub('_', str(locator)) |
396 | 413 |
| 414 |
397 def _get_difference_locator(expected_image_locator, actual_image_locator): | 415 def _get_difference_locator(expected_image_locator, actual_image_locator): |
398 """Returns the locator string used to look up the diffs between expected_image | 416 """Returns the locator string used to look up the diffs between expected_image |
399 and actual_image. | 417 and actual_image. |
400 | 418 |
| 419 We must keep this function in sync with getImageDiffRelativeUrl() in |
| 420 static/loader.js |
| 421 |
401 Args: | 422 Args: |
402 expected_image_locator: locator string pointing at expected image | 423 expected_image_locator: locator string pointing at expected image |
403 actual_image_locator: locator string pointing at actual image | 424 actual_image_locator: locator string pointing at actual image |
404 | 425 |
405 Returns: already-sanitized locator where the diffs between expected and | 426 Returns: already-sanitized locator where the diffs between expected and |
406 actual images can be found | 427 actual images can be found |
407 """ | 428 """ |
408 return "%s-vs-%s" % (_sanitize_locator(expected_image_locator), | 429 return "%s-vs-%s" % (_sanitize_locator(expected_image_locator), |
409 _sanitize_locator(actual_image_locator)) | 430 _sanitize_locator(actual_image_locator)) |
OLD | NEW |