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

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: Created 6 years, 6 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
OLDNEW
1 #!/usr/bin/python 1 #!/usr/bin/python
epoger 2014/06/12 07:02:07 Toplevel comments: This CL is not ready for line-
djsollen 2014/06/12 13:27:00 Building in Release should get a speed up in that
epoger 2014/06/12 14:02:34 Right. I guess to get the most out of multithread
epoger 2014/06/12 14:02:34 My main concern is that we are passing the same im
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 expected_image = _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 actual_image = _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 # EPOGER: check for 80 columns throughout
epoger 2014/06/12 07:02:07 These EPOGERs indicate changes that I *know* I wan
115 # max_diff_per_channel. 105 # Get all diff images and values from skpdiff binary.
116 diff_image = _generate_image_diff(actual_image, expected_image) 106 skpdiff_output_dir = tempfile.mkdtemp()
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: 107 try:
134 skpdiff_csv_output = os.path.join(skpdiff_csv_dir, 'skpdiff-output.csv') 108 skpdiff_summary_file = os.path.join(skpdiff_output_dir,
109 'skpdiff-output.json')
110 skpdiff_rgbdiff_dir = os.path.join(skpdiff_output_dir, 'rgbDiff')
111 skpdiff_whitediff_dir = os.path.join(skpdiff_output_dir, 'whiteDiff')
135 expected_img = os.path.join(storage_root, expected_images_subdir, 112 expected_img = os.path.join(storage_root, expected_images_subdir,
136 str(expected_image_locator) + image_suffix) 113 str(expected_image_locator) + image_suffix)
137 actual_img = os.path.join(storage_root, actual_images_subdir, 114 actual_img = os.path.join(storage_root, actual_images_subdir,
138 str(actual_image_locator) + image_suffix) 115 str(actual_image_locator) + image_suffix)
116
117 # TODO: Call skpdiff ONCE for all image pairs, instead of calling it
118 # repeatedly.
139 find_run_binary.run_command( 119 find_run_binary.run_command(
140 [SKPDIFF_BINARY, '-p', expected_img, actual_img, 120 [SKPDIFF_BINARY, '-p', expected_img, actual_img,
141 '--csv', skpdiff_csv_output, '-d', 'perceptual']) 121 '--jsonp', 'false',
142 with contextlib.closing(open(skpdiff_csv_output)) as csv_file: 122 '--output', skpdiff_summary_file,
143 for row in csv.DictReader(csv_file): 123 '--differs', 'perceptual', 'different_pixels',
epoger 2014/06/12 07:02:07 This is one cause of the slowdown... we just to te
djsollen 2014/06/12 13:27:00 If you're not specifying the number of threads it
epoger 2014/06/12 14:02:34 Good explanation, thanks. I definitely want to ex
144 perceptual_similarity = float(row[' perceptual'].strip()) 124 '--rgbDiffDir', skpdiff_rgbdiff_dir,
145 if not 0 <= perceptual_similarity <= 1: 125 '--whiteDiffDir', skpdiff_whitediff_dir,
146 # skpdiff outputs -1 if the images are different sizes. Treat any 126 ])
147 # output that does not lie in [0, 1] as having 0% perceptual 127
148 # similarity. 128 # Get information out of the skpdiff_summary_file.
149 perceptual_similarity = 0 129 with contextlib.closing(open(skpdiff_summary_file)) as fp:
150 # skpdiff returns the perceptual similarity, convert it to get the 130 data = json.load(fp)
151 # perceptual difference percentage. 131
152 self._perceptual_difference = 100 - (perceptual_similarity * 100) 132 # For now, we can assume there is only one record in the output summary,
133 # since we passed skpdiff only one pair of images.
134 record = data['records'][0]
135 self._width = record['width']
136 self._height = record['height']
137 self._max_diff_per_channel = [
138 record['maxRedDiff'], record['maxGreenDiff'], record['maxBlueDiff']]
139 per_differ_stats = record['diffs']
140 for stats in per_differ_stats:
141 differ_name = stats['differName']
142 if differ_name == 'different_pixels':
143 self._num_pixels_differing = stats['pointsOfInterest']
144 elif differ_name == 'perceptual':
145 perceptual_similarity = stats['result']
146
147 # skpdiff returns the perceptual similarity; convert it to get the
148 # perceptual difference percentage.
149 # skpdiff outputs -1 if the images are different sizes. Treat any
150 # output that does not lie in [0, 1] as having 0% perceptual
151 # similarity.
152 if not 0 <= perceptual_similarity <= 1:
153 perceptual_similarity = 0
154 self._perceptual_difference = 100 - (perceptual_similarity * 100)
155
156 # Store the rgbdiff and whitediff images generated above.
157 diff_image_locator = _get_difference_locator(
158 expected_image_locator=expected_image_locator,
159 actual_image_locator=actual_image_locator)
160 basename = str(diff_image_locator) + image_suffix
161 _mkdir_unless_exists(os.path.join(storage_root, RGBDIFFS_SUBDIR))
162 _mkdir_unless_exists(os.path.join(storage_root, WHITEDIFFS_SUBDIR))
163 # TODO: we could just os.rename() the file, if we knew that
164 # skpdiff_whitediff_dir was on the same filesystem. But it's not.
165 #
166 # EPOGER: instead of _find_single_file_in_dir(), we can now use
epoger 2014/06/12 07:02:07 If we could make skpdiff write its output directly
djsollen 2014/06/12 13:26:59 That shouldn't be too hard.
epoger 2014/06/12 14:02:34 The main problem is that right now you can tell sk
167 # rgbDiffPath and whiteDiffPath within the skpdiff summary
168 shutil.copyfile(_find_single_file_in_dir(skpdiff_rgbdiff_dir),
169 os.path.join(storage_root, RGBDIFFS_SUBDIR, basename))
170 shutil.copyfile(_find_single_file_in_dir(skpdiff_whitediff_dir),
171 os.path.join(storage_root, WHITEDIFFS_SUBDIR, basename))
172
153 finally: 173 finally:
154 shutil.rmtree(skpdiff_csv_dir) 174 shutil.rmtree(skpdiff_output_dir)
epoger 2014/06/12 07:02:07 All of these writing files and then deleting direc
155 175
156 # Final touches on diff_image: use whitediff_image as an alpha mask. 176 # EPOGER: how many of these methods are no longer used?
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
175 def get_num_pixels_differing(self): 177 def get_num_pixels_differing(self):
176 """Returns the absolute number of pixels that differ.""" 178 """Returns the absolute number of pixels that differ."""
177 return self._num_pixels_differing 179 return self._num_pixels_differing
178 180
179 def get_percent_pixels_differing(self): 181 def get_percent_pixels_differing(self):
180 """Returns the percentage of pixels that differ, as a float between 182 """Returns the percentage of pixels that differ, as a float between
181 0 and 100 (inclusive).""" 183 0 and 100 (inclusive)."""
182 return ((float(self._num_pixels_differing) * 100) / 184 return ((float(self._num_pixels_differing) * 100) /
183 (self._width * self._height)) 185 (self._width * self._height))
184 186
(...skipping 113 matching lines...) Expand 10 before | Expand all | Expand 10 after
298 min_index = band * VALUES_PER_BAND 300 min_index = band * VALUES_PER_BAND
299 index = min_index + VALUES_PER_BAND 301 index = min_index + VALUES_PER_BAND
300 while index > min_index: 302 while index > min_index:
301 index -= 1 303 index -= 1
302 if histogram[index] > 0: 304 if histogram[index] > 0:
303 max_per_band.append(index - min_index) 305 max_per_band.append(index - min_index)
304 break 306 break
305 return max_per_band 307 return max_per_band
306 308
307 309
308 def _generate_image_diff(image1, image2): 310 def _download_file(local_filepath, url):
309 """Wrapper for ImageChops.difference(image1, image2) that will handle some 311 """Download a file from url to local_filepath, unless it is already there.
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 312
334 Args: 313 Args:
335 local_filepath: path on local disk where the image should be stored 314 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 315 url: URL from which we can download the image if we don't have it yet
337
338 Returns: a PIL image object
339 """ 316 """
340 if not os.path.exists(local_filepath): 317 if not os.path.exists(local_filepath):
341 _mkdir_unless_exists(os.path.dirname(local_filepath)) 318 _mkdir_unless_exists(os.path.dirname(local_filepath))
342 with contextlib.closing(urllib.urlopen(url)) as url_handle: 319 with contextlib.closing(urllib.urlopen(url)) as url_handle:
343 with open(local_filepath, 'wb') as file_handle: 320 with open(local_filepath, 'wb') as file_handle:
344 shutil.copyfileobj(fsrc=url_handle, fdst=file_handle) 321 shutil.copyfileobj(fsrc=url_handle, fdst=file_handle)
345 return _open_image(local_filepath)
346 322
347 323
348 def _open_image(filepath): 324 def _find_single_file_in_dir(dirpath):
349 """Wrapper for Image.open(filepath) that yields more useful error messages. 325 """Returns the full path of the lone file within this directory.
326
327 If the directory does not exist, contains no files, or contains multiple
328 files, this will raise an exception.
350 329
351 Args: 330 Args:
352 filepath: path on local disk to load image from 331 dirpath: path to a directory on local disk
353
354 Returns: a PIL image object
355 """ 332 """
356 try: 333 filenames = os.listdir(dirpath)
357 return Image.open(filepath) 334 if len(filenames) != 1:
358 except IOError: 335 raise Exception('dir %s does not contain exactly one file: %s' % (
359 # If we are unable to load an image from the file, delete it from disk 336 dirpath, filenames))
360 # and we will try to fetch it again next time. Fixes http://skbug.com/2247 337 return os.path.join(dirpath, filenames[0])
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 338
378 339
379 def _mkdir_unless_exists(path): 340 def _mkdir_unless_exists(path):
380 """Unless path refers to an already-existing directory, create it. 341 """Unless path refers to an already-existing directory, create it.
381 342
382 Args: 343 Args:
383 path: path on local disk 344 path: path on local disk
384 """ 345 """
385 if not os.path.isdir(path): 346 if not os.path.isdir(path):
386 os.makedirs(path) 347 os.makedirs(path)
(...skipping 18 matching lines...) Expand all
405 366
406 Args: 367 Args:
407 expected_image_locator: locator string pointing at expected image 368 expected_image_locator: locator string pointing at expected image
408 actual_image_locator: locator string pointing at actual image 369 actual_image_locator: locator string pointing at actual image
409 370
410 Returns: already-sanitized locator where the diffs between expected and 371 Returns: already-sanitized locator where the diffs between expected and
411 actual images can be found 372 actual images can be found
412 """ 373 """
413 return "%s-vs-%s" % (_sanitize_locator(expected_image_locator), 374 return "%s-vs-%s" % (_sanitize_locator(expected_image_locator),
414 _sanitize_locator(actual_image_locator)) 375 _sanitize_locator(actual_image_locator))
OLDNEW
« no previous file with comments | « no previous file | gm/rebaseline_server/imagepair_test.py » ('j') | gm/rebaseline_server/imagepair_test.py » ('J')

Powered by Google App Engine
This is Rietveld 408576698