bitmap.py revision cedac228d2dd51db4b79ea1e72c7f249408ee061
1# Copyright 2013 The Chromium Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5""" 6Bitmap is a basic wrapper for image pixels. It includes some basic processing 7tools: crop, find bounding box of a color and compute histogram of color values. 8""" 9 10import array 11import base64 12import cStringIO 13import collections 14import struct 15import subprocess 16 17from telemetry.core import util 18from telemetry.core import platform 19from telemetry.util import support_binaries 20 21util.AddDirToPythonPath(util.GetTelemetryDir(), 'third_party', 'png') 22import png # pylint: disable=F0401 23 24 25def HistogramDistance(hist1, hist2): 26 """Earth mover's distance. 27 28 http://en.wikipedia.org/wiki/Earth_mover's_distance 29 First, normalize the two histograms. Then, treat the two histograms as 30 piles of dirt, and calculate the cost of turning one pile into the other. 31 32 To do this, calculate the difference in one bucket between the two 33 histograms. Then carry it over in the calculation for the next bucket. 34 In this way, the difference is weighted by how far it has to move.""" 35 if len(hist1) != len(hist2): 36 raise ValueError('Trying to compare histograms ' 37 'of different sizes, %s != %s' % (len(hist1), len(hist2))) 38 39 n1 = sum(hist1) 40 n2 = sum(hist2) 41 if n1 == 0: 42 raise ValueError('First histogram has 0 pixels in it.') 43 if n2 == 0: 44 raise ValueError('Second histogram has 0 pixels in it.') 45 46 total = 0 47 remainder = 0 48 for value1, value2 in zip(hist1, hist2): 49 remainder += value1 * n2 - value2 * n1 50 total += abs(remainder) 51 assert remainder == 0, ( 52 '%s pixel(s) left over after computing histogram distance.' 53 % abs(remainder)) 54 return abs(float(total) / n1 / n2) 55 56 57class ColorHistogram( 58 collections.namedtuple('ColorHistogram', ['r', 'g', 'b', 'default_color'])): 59 # pylint: disable=W0232 60 # pylint: disable=E1002 61 62 def __new__(cls, r, g, b, default_color=None): 63 return super(ColorHistogram, cls).__new__(cls, r, g, b, default_color) 64 65 def Distance(self, other): 66 total = 0 67 for i in xrange(3): 68 hist1 = self[i] 69 hist2 = other[i] 70 71 if sum(self[i]) == 0: 72 if not self.default_color: 73 raise ValueError('Histogram has no data and no default color.') 74 hist1 = [0] * 256 75 hist1[self.default_color[i]] = 1 76 if sum(other[i]) == 0: 77 if not other.default_color: 78 raise ValueError('Histogram has no data and no default color.') 79 hist2 = [0] * 256 80 hist2[other.default_color[i]] = 1 81 82 total += HistogramDistance(hist1, hist2) 83 return total 84 85 86class RgbaColor(collections.namedtuple('RgbaColor', ['r', 'g', 'b', 'a'])): 87 """Encapsulates an RGBA color retreived from a Bitmap""" 88 # pylint: disable=W0232 89 # pylint: disable=E1002 90 91 def __new__(cls, r, g, b, a=255): 92 return super(RgbaColor, cls).__new__(cls, r, g, b, a) 93 94 def __int__(self): 95 return (self.r << 16) | (self.g << 8) | self.b 96 97 def IsEqual(self, expected_color, tolerance=0): 98 """Verifies that the color is within a given tolerance of 99 the expected color""" 100 r_diff = abs(self.r - expected_color.r) 101 g_diff = abs(self.g - expected_color.g) 102 b_diff = abs(self.b - expected_color.b) 103 a_diff = abs(self.a - expected_color.a) 104 return (r_diff <= tolerance and g_diff <= tolerance 105 and b_diff <= tolerance and a_diff <= tolerance) 106 107 def AssertIsRGB(self, r, g, b, tolerance=0): 108 assert self.IsEqual(RgbaColor(r, g, b), tolerance) 109 110 def AssertIsRGBA(self, r, g, b, a, tolerance=0): 111 assert self.IsEqual(RgbaColor(r, g, b, a), tolerance) 112 113 114WEB_PAGE_TEST_ORANGE = RgbaColor(222, 100, 13) 115WHITE = RgbaColor(255, 255, 255) 116 117 118class _BitmapTools(object): 119 """Wraps a child process of bitmaptools and allows for one command.""" 120 CROP_PIXELS = 0 121 HISTOGRAM = 1 122 BOUNDING_BOX = 2 123 124 def __init__(self, dimensions, pixels): 125 binary = support_binaries.FindPath( 126 'bitmaptools', platform.GetHostPlatform().GetOSName()) 127 assert binary, 'You must build bitmaptools first!' 128 129 self._popen = subprocess.Popen([binary], 130 stdin=subprocess.PIPE, 131 stdout=subprocess.PIPE, 132 stderr=subprocess.PIPE) 133 134 # dimensions are: bpp, width, height, boxleft, boxtop, boxwidth, boxheight 135 packed_dims = struct.pack('iiiiiii', *dimensions) 136 self._popen.stdin.write(packed_dims) 137 # If we got a list of ints, we need to convert it into a byte buffer. 138 if type(pixels) is not bytearray: 139 pixels = bytearray(pixels) 140 self._popen.stdin.write(pixels) 141 142 def _RunCommand(self, *command): 143 assert not self._popen.stdin.closed, ( 144 'Exactly one command allowed per instance of tools.') 145 packed_command = struct.pack('i' * len(command), *command) 146 self._popen.stdin.write(packed_command) 147 self._popen.stdin.close() 148 length_packed = self._popen.stdout.read(struct.calcsize('i')) 149 if not length_packed: 150 raise Exception(self._popen.stderr.read()) 151 length = struct.unpack('i', length_packed)[0] 152 return self._popen.stdout.read(length) 153 154 def CropPixels(self): 155 return self._RunCommand(_BitmapTools.CROP_PIXELS) 156 157 def Histogram(self, ignore_color, tolerance): 158 ignore_color_int = -1 if ignore_color is None else int(ignore_color) 159 response = self._RunCommand(_BitmapTools.HISTOGRAM, 160 ignore_color_int, tolerance) 161 out = array.array('i') 162 out.fromstring(response) 163 assert len(out) == 768, ( 164 'The ColorHistogram has the wrong number of buckets: %s' % len(out)) 165 return ColorHistogram(out[:256], out[256:512], out[512:], ignore_color) 166 167 def BoundingBox(self, color, tolerance): 168 response = self._RunCommand(_BitmapTools.BOUNDING_BOX, int(color), 169 tolerance) 170 unpacked = struct.unpack('iiiii', response) 171 box, count = unpacked[:4], unpacked[-1] 172 if box[2] < 0 or box[3] < 0: 173 box = None 174 return box, count 175 176 177class Bitmap(object): 178 """Utilities for parsing and inspecting a bitmap.""" 179 180 def __init__(self, bpp, width, height, pixels, metadata=None): 181 assert bpp in [3, 4], 'Invalid bytes per pixel' 182 assert width > 0, 'Invalid width' 183 assert height > 0, 'Invalid height' 184 assert pixels, 'Must specify pixels' 185 assert bpp * width * height == len(pixels), 'Dimensions and pixels mismatch' 186 187 self._bpp = bpp 188 self._width = width 189 self._height = height 190 self._pixels = pixels 191 self._metadata = metadata or {} 192 self._crop_box = None 193 194 @property 195 def bpp(self): 196 """Bytes per pixel.""" 197 return self._bpp 198 199 @property 200 def width(self): 201 """Width of the bitmap.""" 202 return self._crop_box[2] if self._crop_box else self._width 203 204 @property 205 def height(self): 206 """Height of the bitmap.""" 207 return self._crop_box[3] if self._crop_box else self._height 208 209 def _PrepareTools(self): 210 """Prepares an instance of _BitmapTools which allows exactly one command. 211 """ 212 crop_box = self._crop_box or (0, 0, self._width, self._height) 213 return _BitmapTools((self._bpp, self._width, self._height) + crop_box, 214 self._pixels) 215 216 @property 217 def pixels(self): 218 """Flat pixel array of the bitmap.""" 219 if self._crop_box: 220 self._pixels = self._PrepareTools().CropPixels() 221 _, _, self._width, self._height = self._crop_box 222 self._crop_box = None 223 if type(self._pixels) is not bytearray: 224 self._pixels = bytearray(self._pixels) 225 return self._pixels 226 227 @property 228 def metadata(self): 229 self._metadata['size'] = (self.width, self.height) 230 self._metadata['alpha'] = self.bpp == 4 231 self._metadata['bitdepth'] = 8 232 return self._metadata 233 234 def GetPixelColor(self, x, y): 235 """Returns a RgbaColor for the pixel at (x, y).""" 236 pixels = self.pixels 237 base = self._bpp * (y * self._width + x) 238 if self._bpp == 4: 239 return RgbaColor(pixels[base + 0], pixels[base + 1], 240 pixels[base + 2], pixels[base + 3]) 241 return RgbaColor(pixels[base + 0], pixels[base + 1], 242 pixels[base + 2]) 243 244 def WritePngFile(self, path): 245 with open(path, "wb") as f: 246 png.Writer(**self.metadata).write_array(f, self.pixels) 247 248 @staticmethod 249 def FromPng(png_data): 250 width, height, pixels, meta = png.Reader(bytes=png_data).read_flat() 251 return Bitmap(4 if meta['alpha'] else 3, width, height, pixels, meta) 252 253 @staticmethod 254 def FromPngFile(path): 255 with open(path, "rb") as f: 256 return Bitmap.FromPng(f.read()) 257 258 @staticmethod 259 def FromBase64Png(base64_png): 260 return Bitmap.FromPng(base64.b64decode(base64_png)) 261 262 def IsEqual(self, other, tolerance=0): 263 """Determines whether two Bitmaps are identical within a given tolerance.""" 264 265 # Dimensions must be equal 266 if self.width != other.width or self.height != other.height: 267 return False 268 269 # Loop over each pixel and test for equality 270 if tolerance or self.bpp != other.bpp: 271 for y in range(self.height): 272 for x in range(self.width): 273 c0 = self.GetPixelColor(x, y) 274 c1 = other.GetPixelColor(x, y) 275 if not c0.IsEqual(c1, tolerance): 276 return False 277 else: 278 return self.pixels == other.pixels 279 280 return True 281 282 def Diff(self, other): 283 """Returns a new Bitmap that represents the difference between this image 284 and another Bitmap.""" 285 286 # Output dimensions will be the maximum of the two input dimensions 287 out_width = max(self.width, other.width) 288 out_height = max(self.height, other.height) 289 290 diff = [[0 for x in xrange(out_width * 3)] for x in xrange(out_height)] 291 292 # Loop over each pixel and write out the difference 293 for y in range(out_height): 294 for x in range(out_width): 295 if x < self.width and y < self.height: 296 c0 = self.GetPixelColor(x, y) 297 else: 298 c0 = RgbaColor(0, 0, 0, 0) 299 300 if x < other.width and y < other.height: 301 c1 = other.GetPixelColor(x, y) 302 else: 303 c1 = RgbaColor(0, 0, 0, 0) 304 305 offset = x * 3 306 diff[y][offset] = abs(c0.r - c1.r) 307 diff[y][offset+1] = abs(c0.g - c1.g) 308 diff[y][offset+2] = abs(c0.b - c1.b) 309 310 # This particular method can only save to a file, so the result will be 311 # written into an in-memory buffer and read back into a Bitmap 312 diff_img = png.from_array(diff, mode='RGB') 313 output = cStringIO.StringIO() 314 try: 315 diff_img.save(output) 316 diff = Bitmap.FromPng(output.getvalue()) 317 finally: 318 output.close() 319 320 return diff 321 322 def GetBoundingBox(self, color, tolerance=0): 323 """Finds the minimum box surrounding all occurences of |color|. 324 Returns: (top, left, width, height), match_count 325 Ignores the alpha channel.""" 326 return self._PrepareTools().BoundingBox(color, tolerance) 327 328 def Crop(self, left, top, width, height): 329 """Crops the current bitmap down to the specified box.""" 330 cur_box = self._crop_box or (0, 0, self._width, self._height) 331 cur_left, cur_top, cur_width, cur_height = cur_box 332 333 if (left < 0 or top < 0 or 334 (left + width) > cur_width or 335 (top + height) > cur_height): 336 raise ValueError('Invalid dimensions') 337 338 self._crop_box = cur_left + left, cur_top + top, width, height 339 return self 340 341 def ColorHistogram(self, ignore_color=None, tolerance=0): 342 """Computes a histogram of the pixel colors in this Bitmap. 343 Args: 344 ignore_color: An RgbaColor to exclude from the bucket counts. 345 tolerance: A tolerance for the ignore_color. 346 347 Returns: 348 A ColorHistogram namedtuple with 256 integers in each field: r, g, and b. 349 """ 350 return self._PrepareTools().Histogram(ignore_color, tolerance) 351