| 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 |