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