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

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

Issue 82823005: rebaseline_server: improve pixel diff reporting (Closed) Base URL: http://skia.googlecode.com/svn/trunk/
Patch Set: Created 7 years 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 | Annotate | Revision Log
« no previous file with comments | « no previous file | gm/rebaseline_server/imagediffdb_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 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
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
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): This is just a wild guess at an appropriate metric.
189 # In the long term, we will probably use some metric generated by 205 # In the long term, we will probably use some metric generated by
190 # skpdiff anyway. 206 # skpdiff anyway.
191 (width, height) = image.size 207 assert(len(histogram) % VALUES_PER_BAND == 0)
192 maxdiff = 3 * (width * height) * 255**2 208 num_bands = len(histogram) / VALUES_PER_BAND
193 h = image.histogram() 209 max_diff = num_pixels * num_bands * (VALUES_PER_BAND - 1)**2
194 assert(len(h) % 256 == 0) 210 total_diff = sum(map(lambda index,value: value * (index % VALUES_PER_BAND)**2,
mtklein 2013/11/22 18:47:34 I think this expression may have tipped over into
epoger 2013/11/22 19:05:18 I completely agree. I was just trying to be one o
195 totaldiff = sum(map(lambda index,value: value * (index%256)**2, 211 xrange(len(histogram)), histogram))
196 range(len(h)), h)) 212 return float(100 * total_diff) / max_diff
197 return float(100 * totaldiff) / maxdiff 213
214 def _max_per_band(histogram):
215 """Given the histogram of an image, return the maximum value of each band
216 (a.k.a. "color channel", such as R/G/B) across the entire image.
217
218 Args:
219 histogram: PIL histogram
220
221 Returns the maximum value of each band within the image histogram, as a list.
222 """
223 max_per_band = []
224 assert(len(histogram) % VALUES_PER_BAND == 0)
225 num_bands = len(histogram) / VALUES_PER_BAND
226 for band in xrange(num_bands):
227 # Assuming that VALUES_PER_BAND is 256...
228 # the 'R' band makes up indices 0-255 in the histogram,
229 # the 'G' band makes up indices 256-511 in the histogram,
230 # etc.
231 min_index = band * VALUES_PER_BAND
232 index = min_index + VALUES_PER_BAND
233 while index > min_index:
234 index -= 1
235 if histogram[index] > 0:
236 max_per_band.append(index - min_index)
237 break
238 return max_per_band
198 239
199 def _generate_image_diff(image1, image2): 240 def _generate_image_diff(image1, image2):
200 """Wrapper for ImageChops.difference(image1, image2) that will handle some 241 """Wrapper for ImageChops.difference(image1, image2) that will handle some
201 errors automatically, or at least yield more useful error messages. 242 errors automatically, or at least yield more useful error messages.
202 243
203 TODO(epoger): Currently, some of the images generated by the bots are RGBA 244 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 245 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. 246 within the UI, convert all to RGB when diffing.
206 247
207 Args: 248 Args:
(...skipping 33 matching lines...) Expand 10 before | Expand all | Expand 10 after
241 filepath: path on local disk to load image from 282 filepath: path on local disk to load image from
242 283
243 Returns: a PIL image object 284 Returns: a PIL image object
244 """ 285 """
245 try: 286 try:
246 return Image.open(filepath) 287 return Image.open(filepath)
247 except IOError: 288 except IOError:
248 logging.error('IOError loading image file %s' % filepath) 289 logging.error('IOError loading image file %s' % filepath)
249 raise 290 raise
250 291
292 def _save_image(image, filepath, format='PNG'):
293 """Write an image to disk, creating any intermediate directories as needed.
294
295 Args:
296 image: a PIL image object
297 filepath: path on local disk to write image to
298 format: one of the PIL image formats, listed at
299 http://effbot.org/imagingbook/formats.htm
300 """
301 _mkdir_unless_exists(os.path.dirname(filepath))
302 image.save(filepath, format)
303
251 def _mkdir_unless_exists(path): 304 def _mkdir_unless_exists(path):
252 """Unless path refers to an already-existing directory, create it. 305 """Unless path refers to an already-existing directory, create it.
253 306
254 Args: 307 Args:
255 path: path on local disk 308 path: path on local disk
256 """ 309 """
257 if not os.path.isdir(path): 310 if not os.path.isdir(path):
258 os.makedirs(path) 311 os.makedirs(path)
259 312
260 def _get_difference_locator(expected_image_locator, actual_image_locator): 313 def _get_difference_locator(expected_image_locator, actual_image_locator):
261 """Returns the locator string used to look up the diffs between expected_image 314 """Returns the locator string used to look up the diffs between expected_image
262 and actual_image. 315 and actual_image.
263 316
264 Args: 317 Args:
265 expected_image_locator: locator string pointing at expected image 318 expected_image_locator: locator string pointing at expected image
266 actual_image_locator: locator string pointing at actual image 319 actual_image_locator: locator string pointing at actual image
267 320
268 Returns: locator where the diffs between expected and actual images can be 321 Returns: locator where the diffs between expected and actual images can be
269 found 322 found
270 """ 323 """
271 return "%s-vs-%s" % (expected_image_locator, actual_image_locator) 324 return "%s-vs-%s" % (expected_image_locator, actual_image_locator)
OLDNEW
« no previous file with comments | « no previous file | gm/rebaseline_server/imagediffdb_test.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698