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

Side by Side Diff: tools/telemetry/telemetry/core/bitmap.py

Issue 668753002: [Telemetry] Migrate bitmap.py from bitmaptools.cc to numpy (Closed) Base URL: https://chromium.googlesource.com/chromium/src.git@master
Patch Set: Make GetBoundingBox just, like, WAY too fast Created 6 years, 1 month 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 | « tools/telemetry/telemetry.gyp ('k') | tools/telemetry/telemetry/core/bitmap_unittest.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 # Copyright 2013 The Chromium Authors. All rights reserved. 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 2 # Use of this source code is governed by a BSD-style license that can be
3 # found in the LICENSE file. 3 # found in the LICENSE file.
4 4
5 """Bitmap is a basic wrapper for an openCV image.
6
7 It includes some basic processing tools: crop, find bounding box of a color
8 and compute histogram of color values.
5 """ 9 """
6 Bitmap is a basic wrapper for image pixels. It includes some basic processing 10 from __future__ import division
7 tools: crop, find bounding box of a color and compute histogram of color values.
8 """
9 11
10 import array
11 import base64 12 import base64
12 import cStringIO
13 import collections 13 import collections
14 import struct 14 import cv2
15 import subprocess 15 import numpy as np
16 16
17 from telemetry.core import util 17 from telemetry.core import util
18 from telemetry.core import platform
19 from telemetry.util import support_binaries
20
21 util.AddDirToPythonPath(util.GetTelemetryDir(), 'third_party', 'png') 18 util.AddDirToPythonPath(util.GetTelemetryDir(), 'third_party', 'png')
22 import png # pylint: disable=F0401 19 import png # pylint: disable=F0401
23 20
24 21
25 def HistogramDistance(hist1, hist2): 22 def HistogramDistance(hist1, hist2):
26 """Earth mover's distance. 23 """Earth mover's distance.
27
28 http://en.wikipedia.org/wiki/Earth_mover's_distance 24 http://en.wikipedia.org/wiki/Earth_mover's_distance
29 First, normalize the two histograms. Then, treat the two histograms as 25 """
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): 26 if len(hist1) != len(hist2):
36 raise ValueError('Trying to compare histograms ' 27 raise ValueError('Trying to compare histograms '
37 'of different sizes, %s != %s' % (len(hist1), len(hist2))) 28 'of different sizes, %s != %s' % (len(hist1), len(hist2)))
38
39 n1 = sum(hist1) 29 n1 = sum(hist1)
40 n2 = sum(hist2) 30 n2 = sum(hist2)
41 if n1 == 0: 31 if n1 == 0:
42 raise ValueError('First histogram has 0 pixels in it.') 32 raise ValueError('First histogram has 0 pixels in it.')
43 if n2 == 0: 33 if n2 == 0:
44 raise ValueError('Second histogram has 0 pixels in it.') 34 raise ValueError('Second histogram has 0 pixels in it.')
45 35
46 total = 0 36 # Normalize histograms to each other.
47 remainder = 0 37 if n1 != n2:
48 for value1, value2 in zip(hist1, hist2): 38 hist1 = np.divide(hist1, n1 / n2)
49 remainder += value1 * n2 - value2 * n1 39
50 total += abs(remainder) 40 h1 = np.zeros((len(hist1), 2), np.float32)
51 assert remainder == 0, ( 41 h2 = np.zeros((len(hist2), 2), np.float32)
52 '%s pixel(s) left over after computing histogram distance.' 42 for i in xrange(len(h1)):
53 % abs(remainder)) 43 h1[i][0] = hist1[i]
54 return abs(float(total) / n1 / n2) 44 h2[i][0] = hist2[i]
45 h1[i][1] = i
46 h2[i][1] = i
47
48 return cv2.cv.CalcEMD2(cv2.cv.fromarray(h1), cv2.cv.fromarray(h2),
49 cv2.cv.CV_DIST_L2)
50
51
52 def ColorsAreEqual(color1, color2, tolerance=0):
53 """Tests whether two colors are equal within the given tolerance.
54
55 Args:
56 color1, color2: The colors to be compared. Colors must be array-like, and
57 of the same color depth and pixel format. ex: (0, 255, 127)
58 tolerance: Per-channel equality tolerance.
59 """
60 return np.amax(np.abs(np.subtract(color1, color2))) <= tolerance
61
62 def BGRAColorAsInt(color):
63 """Converts a BGRA color to an int.
64
65 Args:
66 color: Tuple (b, g, r) or (b, g, r, a). Each channel must be uint8.
67
68 Returns:
69 An int representation of the color, where the 8 highest order bits are the
70 alpha channel (if present), followed by red, then green, then blue.
71 """
72 int_color = 0
73 for i, channel in enumerate(color):
74 int_color |= channel << i * 8
75 return int_color
55 76
56 77
57 class ColorHistogram( 78 class ColorHistogram(
58 collections.namedtuple('ColorHistogram', ['r', 'g', 'b', 'default_color'])): 79 collections.namedtuple('ColorHistogram', ['r', 'g', 'b', 'default_color'])):
59 # pylint: disable=W0232 80 # pylint: disable=W0232
60 # pylint: disable=E1002 81 # pylint: disable=E1002
61 82
62 def __new__(cls, r, g, b, default_color=None): 83 def __new__(cls, r, g, b, default_color=None):
63 return super(ColorHistogram, cls).__new__(cls, r, g, b, default_color) 84 return super(ColorHistogram, cls).__new__(cls, r, g, b, default_color)
64 85
(...skipping 11 matching lines...) Expand all
76 if sum(other[i]) == 0: 97 if sum(other[i]) == 0:
77 if not other.default_color: 98 if not other.default_color:
78 raise ValueError('Histogram has no data and no default color.') 99 raise ValueError('Histogram has no data and no default color.')
79 hist2 = [0] * 256 100 hist2 = [0] * 256
80 hist2[other.default_color[i]] = 1 101 hist2[other.default_color[i]] = 1
81 102
82 total += HistogramDistance(hist1, hist2) 103 total += HistogramDistance(hist1, hist2)
83 return total 104 return total
84 105
85 106
86 class RgbaColor(collections.namedtuple('RgbaColor', ['r', 'g', 'b', 'a'])): 107 WEB_PAGE_TEST_ORANGE = (222, 100, 13)
87 """Encapsulates an RGBA color retreived from a Bitmap""" 108 WHITE = (255, 255, 255)
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
114 WEB_PAGE_TEST_ORANGE = RgbaColor(222, 100, 13)
115 WHITE = RgbaColor(255, 255, 255)
116
117
118 class _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',
127 platform.GetHostPlatform().GetArchName(),
128 platform.GetHostPlatform().GetOSName())
129 assert binary, 'You must build bitmaptools first!'
130
131 self._popen = subprocess.Popen([binary],
132 stdin=subprocess.PIPE,
133 stdout=subprocess.PIPE,
134 stderr=subprocess.PIPE)
135
136 # dimensions are: bpp, width, height, boxleft, boxtop, boxwidth, boxheight
137 packed_dims = struct.pack('iiiiiii', *dimensions)
138 self._popen.stdin.write(packed_dims)
139 # If we got a list of ints, we need to convert it into a byte buffer.
140 if type(pixels) is not bytearray:
141 pixels = bytearray(pixels)
142 self._popen.stdin.write(pixels)
143
144 def _RunCommand(self, *command):
145 assert not self._popen.stdin.closed, (
146 'Exactly one command allowed per instance of tools.')
147 packed_command = struct.pack('i' * len(command), *command)
148 self._popen.stdin.write(packed_command)
149 self._popen.stdin.close()
150 length_packed = self._popen.stdout.read(struct.calcsize('i'))
151 if not length_packed:
152 raise Exception(self._popen.stderr.read())
153 length = struct.unpack('i', length_packed)[0]
154 return self._popen.stdout.read(length)
155
156 def CropPixels(self):
157 return self._RunCommand(_BitmapTools.CROP_PIXELS)
158
159 def Histogram(self, ignore_color, tolerance):
160 ignore_color_int = -1 if ignore_color is None else int(ignore_color)
161 response = self._RunCommand(_BitmapTools.HISTOGRAM,
162 ignore_color_int, tolerance)
163 out = array.array('i')
164 out.fromstring(response)
165 assert len(out) == 768, (
166 'The ColorHistogram has the wrong number of buckets: %s' % len(out))
167 return ColorHistogram(out[:256], out[256:512], out[512:], ignore_color)
168
169 def BoundingBox(self, color, tolerance):
170 response = self._RunCommand(_BitmapTools.BOUNDING_BOX, int(color),
171 tolerance)
172 unpacked = struct.unpack('iiiii', response)
173 box, count = unpacked[:4], unpacked[-1]
174 if box[2] < 0 or box[3] < 0:
175 box = None
176 return box, count
177 109
178 110
179 class Bitmap(object): 111 class Bitmap(object):
180 """Utilities for parsing and inspecting a bitmap.""" 112 """Utilities for parsing and inspecting a bitmap.
181 113
182 def __init__(self, bpp, width, height, pixels, metadata=None): 114 Attributes:
183 assert bpp in [3, 4], 'Invalid bytes per pixel' 115 image: The underlying numpy array (openCV image).
184 assert width > 0, 'Invalid width' 116 """
185 assert height > 0, 'Invalid height'
186 assert pixels, 'Must specify pixels'
187 assert bpp * width * height == len(pixels), 'Dimensions and pixels mismatch'
188 117
189 self._bpp = bpp 118 def __init__(self, image):
190 self._width = width 119 """Initializes the Bitmap object.
191 self._height = height
192 self._pixels = pixels
193 self._metadata = metadata or {}
194 self._crop_box = None
195 120
196 @property 121 Args:
197 def bpp(self): 122 image: A numpy array (openCV image) indexed by [y][x][0-2], BGR.
198 """Bytes per pixel.""" 123 """
199 return self._bpp 124 self.image = image
125
126 @staticmethod
127 def FromRGBPixels(width, height, pixels, bpp=3):
128 """Create a bitmap from an array of rgb pixels.
129
130 Ignores alpha channel if present.
131
132 Args:
133 width, height: int, the width and height of the image.
134 pixels: The flat array of pixels in the form of [r,g,b[,a],r,g,b[,a],...]
135 bpp: 3 for RGB, 4 for RGBA
136 """
137 img = np.array(pixels, order='F', dtype=np.uint8)
138 img.resize((height, width, bpp))
139 img = cv2.cvtColor(img,
140 cv2.COLOR_RGBA2BGR if bpp == 4 else cv2.COLOR_RGB2BGR)
141 return Bitmap(img)
142
143 @staticmethod
144 def FromImageFile(path):
145 """Create a bitmap from an image file."""
146 img = cv2.imread(path, cv2.CV_LOAD_IMAGE_COLOR)
147 if img is None:
148 raise ValueError('Image at path {0} could not be read'.format(path))
149 return Bitmap(img)
200 150
201 @property 151 @property
202 def width(self): 152 def width(self):
203 """Width of the bitmap.""" 153 """Width of the image."""
204 return self._crop_box[2] if self._crop_box else self._width 154 return self.image.shape[1]
205 155
206 @property 156 @property
207 def height(self): 157 def height(self):
208 """Height of the bitmap.""" 158 """Height of the image."""
209 return self._crop_box[3] if self._crop_box else self._height 159 return self.image.shape[0]
210
211 def _PrepareTools(self):
212 """Prepares an instance of _BitmapTools which allows exactly one command.
213 """
214 crop_box = self._crop_box or (0, 0, self._width, self._height)
215 return _BitmapTools((self._bpp, self._width, self._height) + crop_box,
216 self._pixels)
217
218 @property
219 def pixels(self):
220 """Flat pixel array of the bitmap."""
221 if self._crop_box:
222 self._pixels = self._PrepareTools().CropPixels()
223 _, _, self._width, self._height = self._crop_box
224 self._crop_box = None
225 if type(self._pixels) is not bytearray:
226 self._pixels = bytearray(self._pixels)
227 return self._pixels
228
229 @property
230 def metadata(self):
231 self._metadata['size'] = (self.width, self.height)
232 self._metadata['alpha'] = self.bpp == 4
233 self._metadata['bitdepth'] = 8
234 return self._metadata
235
236 def GetPixelColor(self, x, y):
237 """Returns a RgbaColor for the pixel at (x, y)."""
238 pixels = self.pixels
239 base = self._bpp * (y * self._width + x)
240 if self._bpp == 4:
241 return RgbaColor(pixels[base + 0], pixels[base + 1],
242 pixels[base + 2], pixels[base + 3])
243 return RgbaColor(pixels[base + 0], pixels[base + 1],
244 pixels[base + 2])
245
246 def WritePngFile(self, path):
247 with open(path, "wb") as f:
248 png.Writer(**self.metadata).write_array(f, self.pixels)
249 160
250 @staticmethod 161 @staticmethod
251 def FromPng(png_data): 162 def FromPng(png_data):
252 width, height, pixels, meta = png.Reader(bytes=png_data).read_flat() 163 width, height, pixels, meta = png.Reader(bytes=png_data).read_flat()
253 return Bitmap(4 if meta['alpha'] else 3, width, height, pixels, meta) 164 return Bitmap.FromRGBPixels(
254 165 width, height, pixels, 4 if meta['alpha'] else 3)
255 @staticmethod
256 def FromPngFile(path):
257 with open(path, "rb") as f:
258 return Bitmap.FromPng(f.read())
259 166
260 @staticmethod 167 @staticmethod
261 def FromBase64Png(base64_png): 168 def FromBase64Png(base64_png):
262 return Bitmap.FromPng(base64.b64decode(base64_png)) 169 return Bitmap.FromPng(base64.b64decode(base64_png))
263 170
264 def IsEqual(self, other, tolerance=0): 171 def IsEqual(self, other, tolerance=0):
265 """Determines whether two Bitmaps are identical within a given tolerance.""" 172 """Determines whether two Bitmaps are identical within a given tolerance."""
266 173 if self.image.shape != other.image.shape:
267 # Dimensions must be equal
268 if self.width != other.width or self.height != other.height:
269 return False 174 return False
270 175 diff_img = cv2.absdiff(self.image, other.image)
271 # Loop over each pixel and test for equality 176 return np.amax(diff_img) <= tolerance
272 if tolerance or self.bpp != other.bpp:
273 for y in range(self.height):
274 for x in range(self.width):
275 c0 = self.GetPixelColor(x, y)
276 c1 = other.GetPixelColor(x, y)
277 if not c0.IsEqual(c1, tolerance):
278 return False
279 else:
280 return self.pixels == other.pixels
281
282 return True
283 177
284 def Diff(self, other): 178 def Diff(self, other):
285 """Returns a new Bitmap that represents the difference between this image 179 """Returns a new Bitmap that represents the difference between this Bitmap
286 and another Bitmap.""" 180 and another Bitmap."""
181 self_image = self.image
182 other_image = other.image
183 if self.image.shape[2] != other.image.shape[2]:
184 raise ValueError('Cannot diff images of differing bit depth')
185 if self.image.shape[:2] != other.image.shape[:2]:
186 width = max(self.width, other.width)
187 height = max(self.height, other.height)
188 self_image = np.zeros((width, height, self.image.shape[2]), np.uint8)
189 other_image = np.zeros((width, height, self.image.shape[2]), np.uint8)
190 self_image[0:self.height, 0:self.width] = self.image
191 other_image[0:other.height, 0:other.width] = other.image
287 192
288 # Output dimensions will be the maximum of the two input dimensions 193 return Bitmap(cv2.absdiff(self_image, other_image))
289 out_width = max(self.width, other.width)
290 out_height = max(self.height, other.height)
291
292 diff = [[0 for x in xrange(out_width * 3)] for x in xrange(out_height)]
293
294 # Loop over each pixel and write out the difference
295 for y in range(out_height):
296 for x in range(out_width):
297 if x < self.width and y < self.height:
298 c0 = self.GetPixelColor(x, y)
299 else:
300 c0 = RgbaColor(0, 0, 0, 0)
301
302 if x < other.width and y < other.height:
303 c1 = other.GetPixelColor(x, y)
304 else:
305 c1 = RgbaColor(0, 0, 0, 0)
306
307 offset = x * 3
308 diff[y][offset] = abs(c0.r - c1.r)
309 diff[y][offset+1] = abs(c0.g - c1.g)
310 diff[y][offset+2] = abs(c0.b - c1.b)
311
312 # This particular method can only save to a file, so the result will be
313 # written into an in-memory buffer and read back into a Bitmap
314 diff_img = png.from_array(diff, mode='RGB')
315 output = cStringIO.StringIO()
316 try:
317 diff_img.save(output)
318 diff = Bitmap.FromPng(output.getvalue())
319 finally:
320 output.close()
321
322 return diff
323 194
324 def GetBoundingBox(self, color, tolerance=0): 195 def GetBoundingBox(self, color, tolerance=0):
325 """Finds the minimum box surrounding all occurences of |color|. 196 """Finds the minimum box surrounding all occurrences of bgr |color|.
326 Returns: (top, left, width, height), match_count 197 Returns: (top, left, width, height), match_count
327 Ignores the alpha channel.""" 198 Ignores the alpha channel."""
slamm 2014/10/23 22:57:44 Would you update the doc string to match our style
mthiesse 2014/10/24 14:48:12 Done.
328 return self._PrepareTools().BoundingBox(color, tolerance) 199 img = cv2.inRange(self.image, np.subtract(color, tolerance),
200 np.add(color, tolerance))
201 count = cv2.countNonZero(img)
202 if count == 0:
203 return None, 0
204 contours, _ = cv2.findContours(img, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE)
slamm 2014/10/23 22:57:44 Use cv2.CV_CHAIN_APPROX_SIMPLE? CV_CHAIN_APPROX_S
szym 2014/10/23 23:17:07 Since this is followed by boundingRect, I'm not su
mthiesse 2014/10/24 14:48:12 Acknowledged.
205 return cv2.boundingRect(contours[0]), count
slamm 2014/10/23 22:57:44 I saw some talk of finding the contour with the ma
mthiesse 2014/10/24 14:48:12 Okay, what I've done instead is concatenated any c
329 206
330 def Crop(self, left, top, width, height): 207 def Crop(self, left, top, width, height):
331 """Crops the current bitmap down to the specified box.""" 208 """Crops the current bitmap down to the specified box."""
332 cur_box = self._crop_box or (0, 0, self._width, self._height) 209 img_height, img_width = self.image.shape[:2]
333 cur_left, cur_top, cur_width, cur_height = cur_box
334
335 if (left < 0 or top < 0 or 210 if (left < 0 or top < 0 or
336 (left + width) > cur_width or 211 (left + width) > img_width or
337 (top + height) > cur_height): 212 (top + height) > img_height):
338 raise ValueError('Invalid dimensions') 213 raise ValueError('Invalid dimensions')
339 214 self.image = self.image[top:top + height, left:left + width]
340 self._crop_box = cur_left + left, cur_top + top, width, height
341 return self 215 return self
342 216
343 def ColorHistogram(self, ignore_color=None, tolerance=0): 217 def ColorHistogram(self, ignore_color=None, tolerance=0):
344 """Computes a histogram of the pixel colors in this Bitmap. 218 """Computes a histogram of the pixel colors in this Bitmap.
345 Args: 219 Args:
346 ignore_color: An RgbaColor to exclude from the bucket counts. 220 ignore_color: (b, g, r), A color to exclude from the bucket counts.
347 tolerance: A tolerance for the ignore_color. 221 tolerance: A tolerance for the ignore_color.
348 222
349 Returns: 223 Returns:
350 A ColorHistogram namedtuple with 256 integers in each field: r, g, and b. 224 A ColorHistogram namedtuple with 256 integers in each field: r, g, and b.
351 """ 225 """
352 return self._PrepareTools().Histogram(ignore_color, tolerance) 226 mask = None
227 if ignore_color is not None:
228 mask = ~cv2.inRange(self.image, np.subtract(ignore_color, tolerance),
229 np.add(ignore_color, tolerance))
230
231 flatten = np.ndarray.flatten
232 hist_b = flatten(cv2.calcHist([self.image], [0], mask, [256], [0, 256]))
233 hist_g = flatten(cv2.calcHist([self.image], [1], mask, [256], [0, 256]))
234 hist_r = flatten(cv2.calcHist([self.image], [2], mask, [256], [0, 256]))
235 return ColorHistogram(hist_r, hist_g, hist_b, ignore_color)
OLDNEW
« no previous file with comments | « tools/telemetry/telemetry.gyp ('k') | tools/telemetry/telemetry/core/bitmap_unittest.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698