Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(31)

Side by Side Diff: gm/rebaseline_server/imagediffdb.py

Issue 325413003: rebaseline_server: use just skpdiff, not Python Image Library (Closed) Base URL: https://skia.googlesource.com/skia.git@master
Patch Set: attempt to fix "'abs' : ambiguous call to overloaded function" on Windows Created 6 years, 5 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
« no previous file with comments | « no previous file | gm/rebaseline_server/imagepair_test.py » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
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 csv 13 import json
14 import logging 14 import logging
15 import os 15 import os
16 import re 16 import re
17 import shutil 17 import shutil
18 import sys 18 import sys
19 import tempfile 19 import tempfile
20 import urllib 20 import urllib
21 try:
22 from PIL import Image, ImageChops
23 except ImportError:
24 raise ImportError('Requires PIL to be installed; see '
25 + 'http://www.pythonware.com/products/pil/')
26 21
27 # Set the PYTHONPATH to include the tools directory. 22 # Set the PYTHONPATH to include the tools directory.
28 sys.path.append( 23 sys.path.append(
29 os.path.join( 24 os.path.join(
30 os.path.dirname(os.path.realpath(__file__)), os.pardir, os.pardir, 25 os.path.dirname(os.path.realpath(__file__)), os.pardir, os.pardir,
31 'tools')) 26 'tools'))
32 import find_run_binary 27 import find_run_binary
33 28
34 SKPDIFF_BINARY = find_run_binary.find_path_to_program('skpdiff') 29 SKPDIFF_BINARY = find_run_binary.find_path_to_program('skpdiff')
35 30
36 DEFAULT_IMAGE_SUFFIX = '.png' 31 DEFAULT_IMAGE_SUFFIX = '.png'
37 DEFAULT_IMAGES_SUBDIR = 'images' 32 DEFAULT_IMAGES_SUBDIR = 'images'
38 33
39 DISALLOWED_FILEPATH_CHAR_REGEX = re.compile('[^\w\-]') 34 DISALLOWED_FILEPATH_CHAR_REGEX = re.compile('[^\w\-]')
40 35
41 DIFFS_SUBDIR = 'diffs' 36 RGBDIFFS_SUBDIR = 'diffs'
42 WHITEDIFFS_SUBDIR = 'whitediffs' 37 WHITEDIFFS_SUBDIR = 'whitediffs'
43 38
44 VALUES_PER_BAND = 256
45
46 # Keys used within DiffRecord dictionary representations. 39 # Keys used within DiffRecord dictionary representations.
47 # NOTE: Keep these in sync with static/constants.js 40 # NOTE: Keep these in sync with static/constants.js
48 KEY__DIFFERENCES__MAX_DIFF_PER_CHANNEL = 'maxDiffPerChannel' 41 KEY__DIFFERENCES__MAX_DIFF_PER_CHANNEL = 'maxDiffPerChannel'
49 KEY__DIFFERENCES__NUM_DIFF_PIXELS = 'numDifferingPixels' 42 KEY__DIFFERENCES__NUM_DIFF_PIXELS = 'numDifferingPixels'
50 KEY__DIFFERENCES__PERCENT_DIFF_PIXELS = 'percentDifferingPixels' 43 KEY__DIFFERENCES__PERCENT_DIFF_PIXELS = 'percentDifferingPixels'
51 KEY__DIFFERENCES__PERCEPTUAL_DIFF = 'perceptualDifference' 44 KEY__DIFFERENCES__PERCEPTUAL_DIFF = 'perceptualDifference'
52 45
53 46
54 class DiffRecord(object): 47 class DiffRecord(object):
55 """ Record of differences between two images. """ 48 """ Record of differences between two images. """
(...skipping 24 matching lines...) Expand all
80 actual image within storage_root (probably including a checksum to 73 actual image within storage_root (probably including a checksum to
81 guarantee uniqueness) 74 guarantee uniqueness)
82 expected_images_subdir: the subdirectory expected images are stored in. 75 expected_images_subdir: the subdirectory expected images are stored in.
83 actual_images_subdir: the subdirectory actual images are stored in. 76 actual_images_subdir: the subdirectory actual images are stored in.
84 image_suffix: the suffix of images. 77 image_suffix: the suffix of images.
85 """ 78 """
86 expected_image_locator = _sanitize_locator(expected_image_locator) 79 expected_image_locator = _sanitize_locator(expected_image_locator)
87 actual_image_locator = _sanitize_locator(actual_image_locator) 80 actual_image_locator = _sanitize_locator(actual_image_locator)
88 81
89 # Download the expected/actual images, if we don't have them already. 82 # Download the expected/actual images, if we don't have them already.
90 # TODO(rmistry): Add a parameter that makes _download_and_open_image raise 83 # TODO(rmistry): Add a parameter that just tries to use already-present
91 # an exception if images are not found locally (instead of trying to 84 # image files rather than downloading them.
92 # download them).
93 expected_image_file = os.path.join( 85 expected_image_file = os.path.join(
94 storage_root, expected_images_subdir, 86 storage_root, expected_images_subdir,
95 str(expected_image_locator) + image_suffix) 87 str(expected_image_locator) + image_suffix)
96 actual_image_file = os.path.join( 88 actual_image_file = os.path.join(
97 storage_root, actual_images_subdir, 89 storage_root, actual_images_subdir,
98 str(actual_image_locator) + image_suffix) 90 str(actual_image_locator) + image_suffix)
99 try: 91 try:
100 expected_image = _download_and_open_image( 92 _download_file(expected_image_file, expected_image_url)
101 expected_image_file, expected_image_url)
102 except Exception: 93 except Exception:
103 logging.exception('unable to download expected_image_url %s to file %s' % 94 logging.exception('unable to download expected_image_url %s to file %s' %
104 (expected_image_url, expected_image_file)) 95 (expected_image_url, expected_image_file))
105 raise 96 raise
106 try: 97 try:
107 actual_image = _download_and_open_image( 98 _download_file(actual_image_file, actual_image_url)
108 actual_image_file, actual_image_url)
109 except Exception: 99 except Exception:
110 logging.exception('unable to download actual_image_url %s to file %s' % 100 logging.exception('unable to download actual_image_url %s to file %s' %
111 (actual_image_url, actual_image_file)) 101 (actual_image_url, actual_image_file))
112 raise 102 raise
113 103
114 # Generate the diff image (absolute diff at each pixel) and 104 # Get all diff images and values from skpdiff binary.
115 # max_diff_per_channel. 105 skpdiff_output_dir = tempfile.mkdtemp()
116 diff_image = _generate_image_diff(actual_image, expected_image)
117 diff_histogram = diff_image.histogram()
118 (diff_width, diff_height) = diff_image.size
119 self._max_diff_per_channel = _max_per_band(diff_histogram)
120
121 # Generate the whitediff image (any differing pixels show as white).
122 # This is tricky, because when you convert color images to grayscale or
123 # black & white in PIL, it has its own ideas about thresholds.
124 # We have to force it: if a pixel has any color at all, it's a '1'.
125 bands = diff_image.split()
126 graydiff_image = ImageChops.lighter(ImageChops.lighter(
127 bands[0], bands[1]), bands[2])
128 whitediff_image = (graydiff_image.point(lambda p: p > 0 and VALUES_PER_BAND)
129 .convert('1', dither=Image.NONE))
130
131 # Calculate the perceptual difference percentage.
132 skpdiff_csv_dir = tempfile.mkdtemp()
133 try: 106 try:
134 skpdiff_csv_output = os.path.join(skpdiff_csv_dir, 'skpdiff-output.csv') 107 skpdiff_summary_file = os.path.join(skpdiff_output_dir,
108 'skpdiff-output.json')
109 skpdiff_rgbdiff_dir = os.path.join(skpdiff_output_dir, 'rgbDiff')
110 skpdiff_whitediff_dir = os.path.join(skpdiff_output_dir, 'whiteDiff')
135 expected_img = os.path.join(storage_root, expected_images_subdir, 111 expected_img = os.path.join(storage_root, expected_images_subdir,
136 str(expected_image_locator) + image_suffix) 112 str(expected_image_locator) + image_suffix)
137 actual_img = os.path.join(storage_root, actual_images_subdir, 113 actual_img = os.path.join(storage_root, actual_images_subdir,
138 str(actual_image_locator) + image_suffix) 114 str(actual_image_locator) + image_suffix)
115
116 # TODO: Call skpdiff ONCE for all image pairs, instead of calling it
117 # repeatedly. This will allow us to parallelize a lot more work.
139 find_run_binary.run_command( 118 find_run_binary.run_command(
140 [SKPDIFF_BINARY, '-p', expected_img, actual_img, 119 [SKPDIFF_BINARY, '-p', expected_img, actual_img,
141 '--csv', skpdiff_csv_output, '-d', 'perceptual']) 120 '--jsonp', 'false',
142 with contextlib.closing(open(skpdiff_csv_output)) as csv_file: 121 '--output', skpdiff_summary_file,
143 for row in csv.DictReader(csv_file): 122 '--differs', 'perceptual', 'different_pixels',
144 perceptual_similarity = float(row[' perceptual'].strip()) 123 '--rgbDiffDir', skpdiff_rgbdiff_dir,
145 if not 0 <= perceptual_similarity <= 1: 124 '--whiteDiffDir', skpdiff_whitediff_dir,
146 # skpdiff outputs -1 if the images are different sizes. Treat any 125 ])
147 # output that does not lie in [0, 1] as having 0% perceptual 126
148 # similarity. 127 # Get information out of the skpdiff_summary_file.
149 perceptual_similarity = 0 128 with contextlib.closing(open(skpdiff_summary_file)) as fp:
150 # skpdiff returns the perceptual similarity, convert it to get the 129 data = json.load(fp)
151 # perceptual difference percentage. 130
152 self._perceptual_difference = 100 - (perceptual_similarity * 100) 131 # For now, we can assume there is only one record in the output summary,
132 # since we passed skpdiff only one pair of images.
133 record = data['records'][0]
134 self._width = record['width']
135 self._height = record['height']
136 # TODO: make max_diff_per_channel a tuple instead of a list, because the
137 # structure is meaningful (first element is red, second is green, etc.)
138 # See http://stackoverflow.com/a/626871
139 self._max_diff_per_channel = [
140 record['maxRedDiff'], record['maxGreenDiff'], record['maxBlueDiff']]
141 rgb_diff_path = record['rgbDiffPath']
142 white_diff_path = record['whiteDiffPath']
143 per_differ_stats = record['diffs']
144 for stats in per_differ_stats:
145 differ_name = stats['differName']
146 if differ_name == 'different_pixels':
147 self._num_pixels_differing = stats['pointsOfInterest']
148 elif differ_name == 'perceptual':
149 perceptual_similarity = stats['result']
150
151 # skpdiff returns the perceptual similarity; convert it to get the
152 # perceptual difference percentage.
153 # skpdiff outputs -1 if the images are different sizes. Treat any
154 # output that does not lie in [0, 1] as having 0% perceptual
155 # similarity.
156 if not 0 <= perceptual_similarity <= 1:
157 perceptual_similarity = 0
158 self._perceptual_difference = 100 - (perceptual_similarity * 100)
159
160 # Store the rgbdiff and whitediff images generated above.
161 diff_image_locator = _get_difference_locator(
162 expected_image_locator=expected_image_locator,
163 actual_image_locator=actual_image_locator)
164 basename = str(diff_image_locator) + image_suffix
165 _mkdir_unless_exists(os.path.join(storage_root, RGBDIFFS_SUBDIR))
166 _mkdir_unless_exists(os.path.join(storage_root, WHITEDIFFS_SUBDIR))
167 # TODO: Modify skpdiff's behavior so we can tell it exactly where to
168 # write the image files into, rather than having to move them around
169 # after skpdiff writes them out.
170 shutil.copyfile(rgb_diff_path,
171 os.path.join(storage_root, RGBDIFFS_SUBDIR, basename))
172 shutil.copyfile(white_diff_path,
173 os.path.join(storage_root, WHITEDIFFS_SUBDIR, basename))
174
153 finally: 175 finally:
154 shutil.rmtree(skpdiff_csv_dir) 176 shutil.rmtree(skpdiff_output_dir)
155
156 # Final touches on diff_image: use whitediff_image as an alpha mask.
157 # Unchanged pixels are transparent; differing pixels are opaque.
158 diff_image.putalpha(whitediff_image)
159
160 # Store the diff and whitediff images generated above.
161 diff_image_locator = _get_difference_locator(
162 expected_image_locator=expected_image_locator,
163 actual_image_locator=actual_image_locator)
164 basename = str(diff_image_locator) + image_suffix
165 _save_image(diff_image, os.path.join(
166 storage_root, DIFFS_SUBDIR, basename))
167 _save_image(whitediff_image, os.path.join(
168 storage_root, WHITEDIFFS_SUBDIR, basename))
169
170 # Calculate difference metrics.
171 (self._width, self._height) = diff_image.size
172 self._num_pixels_differing = (
173 whitediff_image.histogram()[VALUES_PER_BAND - 1])
174 177
175 def get_num_pixels_differing(self): 178 def get_num_pixels_differing(self):
176 """Returns the absolute number of pixels that differ.""" 179 """Returns the absolute number of pixels that differ."""
177 return self._num_pixels_differing 180 return self._num_pixels_differing
178 181
179 def get_percent_pixels_differing(self): 182 def get_percent_pixels_differing(self):
180 """Returns the percentage of pixels that differ, as a float between 183 """Returns the percentage of pixels that differ, as a float between
181 0 and 100 (inclusive).""" 184 0 and 100 (inclusive)."""
182 return ((float(self._num_pixels_differing) * 100) / 185 return ((float(self._num_pixels_differing) * 100) /
183 (self._width * self._height)) 186 (self._width * self._height))
(...skipping 87 matching lines...) Expand 10 before | Expand all | Expand 10 after
271 274
272 Raises a KeyError if we don't have a DiffRecord for this image pair. 275 Raises a KeyError if we don't have a DiffRecord for this image pair.
273 """ 276 """
274 key = (_sanitize_locator(expected_image_locator), 277 key = (_sanitize_locator(expected_image_locator),
275 _sanitize_locator(actual_image_locator)) 278 _sanitize_locator(actual_image_locator))
276 return self._diff_dict[key] 279 return self._diff_dict[key]
277 280
278 281
279 # Utility functions 282 # Utility functions
280 283
281 def _max_per_band(histogram): 284 def _download_file(local_filepath, url):
282 """Given the histogram of an image, return the maximum value of each band 285 """Download a file from url to local_filepath, unless it is already there.
283 (a.k.a. "color channel", such as R/G/B) across the entire image.
284
285 Args:
286 histogram: PIL histogram
287
288 Returns the maximum value of each band within the image histogram, as a list.
289 """
290 max_per_band = []
291 assert(len(histogram) % VALUES_PER_BAND == 0)
292 num_bands = len(histogram) / VALUES_PER_BAND
293 for band in xrange(num_bands):
294 # Assuming that VALUES_PER_BAND is 256...
295 # the 'R' band makes up indices 0-255 in the histogram,
296 # the 'G' band makes up indices 256-511 in the histogram,
297 # etc.
298 min_index = band * VALUES_PER_BAND
299 index = min_index + VALUES_PER_BAND
300 while index > min_index:
301 index -= 1
302 if histogram[index] > 0:
303 max_per_band.append(index - min_index)
304 break
305 return max_per_band
306
307
308 def _generate_image_diff(image1, image2):
309 """Wrapper for ImageChops.difference(image1, image2) that will handle some
310 errors automatically, or at least yield more useful error messages.
311
312 TODO(epoger): Currently, some of the images generated by the bots are RGBA
313 and others are RGB. I'm not sure why that is. For now, to avoid confusion
314 within the UI, convert all to RGB when diffing.
315
316 Args:
317 image1: a PIL image object
318 image2: a PIL image object
319
320 Returns: per-pixel diffs between image1 and image2, as a PIL image object
321 """
322 try:
323 return ImageChops.difference(image1.convert('RGB'), image2.convert('RGB'))
324 except ValueError:
325 logging.error('Error diffing image1 [%s] and image2 [%s].' % (
326 repr(image1), repr(image2)))
327 raise
328
329
330 def _download_and_open_image(local_filepath, url):
331 """Open the image at local_filepath; if there is no file at that path,
332 download it from url to that path and then open it.
333 286
334 Args: 287 Args:
335 local_filepath: path on local disk where the image should be stored 288 local_filepath: path on local disk where the image should be stored
336 url: URL from which we can download the image if we don't have it yet 289 url: URL from which we can download the image if we don't have it yet
337
338 Returns: a PIL image object
339 """ 290 """
340 if not os.path.exists(local_filepath): 291 if not os.path.exists(local_filepath):
341 _mkdir_unless_exists(os.path.dirname(local_filepath)) 292 _mkdir_unless_exists(os.path.dirname(local_filepath))
342 with contextlib.closing(urllib.urlopen(url)) as url_handle: 293 with contextlib.closing(urllib.urlopen(url)) as url_handle:
343 with open(local_filepath, 'wb') as file_handle: 294 with open(local_filepath, 'wb') as file_handle:
344 shutil.copyfileobj(fsrc=url_handle, fdst=file_handle) 295 shutil.copyfileobj(fsrc=url_handle, fdst=file_handle)
345 return _open_image(local_filepath)
346
347
348 def _open_image(filepath):
349 """Wrapper for Image.open(filepath) that yields more useful error messages.
350
351 Args:
352 filepath: path on local disk to load image from
353
354 Returns: a PIL image object
355 """
356 try:
357 return Image.open(filepath)
358 except IOError:
359 # If we are unable to load an image from the file, delete it from disk
360 # and we will try to fetch it again next time. Fixes http://skbug.com/2247
361 logging.error('IOError loading image file %s ; deleting it.' % filepath)
362 os.remove(filepath)
363 raise
364
365
366 def _save_image(image, filepath, format='PNG'):
367 """Write an image to disk, creating any intermediate directories as needed.
368
369 Args:
370 image: a PIL image object
371 filepath: path on local disk to write image to
372 format: one of the PIL image formats, listed at
373 http://effbot.org/imagingbook/formats.htm
374 """
375 _mkdir_unless_exists(os.path.dirname(filepath))
376 image.save(filepath, format)
377 296
378 297
379 def _mkdir_unless_exists(path): 298 def _mkdir_unless_exists(path):
380 """Unless path refers to an already-existing directory, create it. 299 """Unless path refers to an already-existing directory, create it.
381 300
382 Args: 301 Args:
383 path: path on local disk 302 path: path on local disk
384 """ 303 """
385 if not os.path.isdir(path): 304 if not os.path.isdir(path):
386 os.makedirs(path) 305 os.makedirs(path)
(...skipping 18 matching lines...) Expand all
405 324
406 Args: 325 Args:
407 expected_image_locator: locator string pointing at expected image 326 expected_image_locator: locator string pointing at expected image
408 actual_image_locator: locator string pointing at actual image 327 actual_image_locator: locator string pointing at actual image
409 328
410 Returns: already-sanitized locator where the diffs between expected and 329 Returns: already-sanitized locator where the diffs between expected and
411 actual images can be found 330 actual images can be found
412 """ 331 """
413 return "%s-vs-%s" % (_sanitize_locator(expected_image_locator), 332 return "%s-vs-%s" % (_sanitize_locator(expected_image_locator),
414 _sanitize_locator(actual_image_locator)) 333 _sanitize_locator(actual_image_locator))
OLDNEW
« no previous file with comments | « no previous file | gm/rebaseline_server/imagepair_test.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698