1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
content / test / gpu / gpu_tests / crop_actions.py [blame]
# Copyright 2024 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Classes for defining how to crop screenshots in pixel-related tests."""
import abc
from typing import Optional, Tuple
from gpu_tests import common_typing as ct
from telemetry.util import image_util
class BaseCropAction(abc.ABC):
@abc.abstractmethod
def CropScreenshot(self, screenshot: ct.Screenshot, dpr: float,
device_type: str, os_name: str) -> ct.Screenshot:
"""Return a cropped copy of |screenshot|.
The exact behavior is dependent on the concrete class.
"""
class NoOpCropAction(BaseCropAction):
def CropScreenshot(self, screenshot: ct.Screenshot, dpr: float,
device_type: str, os_name: str) -> ct.Screenshot:
del dpr, device_type, os_name # unused
return screenshot
class FixedRectCropAction(BaseCropAction):
"""Crops screenshots to the given rectangle.
The rectangle is first scaled based on the device pixel ratio.
"""
# The value needed varies depending on device type, likely due to resolution:
# * Pixel 4: 10
# * Samsung A23: 11
# * Samsung S23: 12
# Use the largest value for simplicity instead of attempting to change it
# dynamically.
SCROLLBAR_WIDTH = 12
def __init__(self, x1: int, y1: int, x2: Optional[int], y2: Optional[int]):
"""
Args:
x1: An int specifying the x coordinate of the top left corner of the crop
rectangle
y1: An int specifying the y coordinate of the top left corner of the crop
rectangle
x2: An int specifying the x coordinate of the bottom right corner of the
crop rectangle. Can be None to explicitly specify the right side of
the image, although clamping will be performed regardless.
y2: An int specifying the y coordinate of the bottom right corner of the
crop rectangle. Can be None to explicitly specify the bottom of the
image, although clamping will be performed regardless.
"""
assert x1 >= 0
assert y1 >= 0
assert x2 is None or x2 > x1
assert y2 is None or y2 > y1
self._x1 = x1
self._y1 = y1
self._x2 = x2
self._y2 = y2
def CropScreenshot(self, screenshot: ct.Screenshot, dpr: float,
device_type: str, os_name: str) -> ct.Screenshot:
del device_type, os_name # unused
start_x = int(self._x1 * dpr)
start_y = int(self._y1 * dpr)
# When actually clamping the value, it's possible we'll catch the
# scrollbar, so account for its width in the clamp.
max_x = image_util.Width(screenshot) - FixedRectCropAction.SCROLLBAR_WIDTH
max_y = image_util.Height(screenshot)
if self._x2 is None:
end_x = max_x
else:
end_x = min(int(self._x2 * dpr), max_x)
if self._y2 is None:
end_y = max_y
else:
end_y = min(int(self._y2 * dpr), max_y)
crop_width = end_x - start_x
crop_height = end_y - start_y
return image_util.Crop(screenshot, start_x, start_y, crop_width,
crop_height)
class NonWhiteContentCropAction(BaseCropAction):
"""Crops screenshots to remove all white (background) content."""
OFF_WHITE_TOP_ROW_DEVICES = {
# Samsung A13.
'SM-A135M',
# Samsung A23.
'SM-A235M',
}
def __init__(self, initial_crop: Optional[BaseCropAction] = None):
"""
Args:
initial_crop: An initial crop to perform before removing the background.
Intended to reduce the amount of work done finding the non-white
content if the content of interest is known to be small relative to
the entire screenshot.
"""
self._initial_crop = initial_crop
def CropScreenshot(self, screenshot: ct.Screenshot, dpr: float,
device_type: str, os_name: str) -> ct.Screenshot:
# The bottom corners of Mac screenshots have black triangles due to the
# rounded corners of Mac windows. So, crop the bottom few rows off now to
# get rid of those.
if os_name == 'mac':
screenshot = image_util.Crop(screenshot, 0, 0,
image_util.Width(screenshot),
image_util.Height(screenshot) - 20)
# GPU tests typically capture screenshots from the OS level codepath instead
# of directly from the web contents. This is because capturing from the
# web contents may cause the content to be re-rendered, which may hide bugs.
# A side effect of this is that browser UI is barely visible in the first
# row of pixels on some devices, which will affect our ability to detect
# the white background. So, preemptively crop off the top row on such
# devices.
if device_type in NonWhiteContentCropAction.OFF_WHITE_TOP_ROW_DEVICES:
screenshot = image_util.Crop(screenshot, 0, 1,
image_util.Width(screenshot),
image_util.Height(screenshot) - 1)
if self._initial_crop:
screenshot = self._initial_crop.CropScreenshot(screenshot, dpr,
device_type, os_name)
x1, y1, x2, y2 = _GetNonWhiteCropBoundaries(screenshot)
return image_util.Crop(screenshot, x1, y1, x2 - x1, y2 - y1)
def _GetNonWhiteCropBoundaries(
screenshot: ct.Screenshot) -> Tuple[int, int, int, int]:
"""Returns the boundaries to crop the screenshot to.
Specifically, we look for the boundaries where the white background
transitions into the (non-white) content we care about.
Returns:
A 4-tuple (x1, y1, x2, y2) denoting the top left and bottom right
coordinates to crop to.
"""
img_height = image_util.Height(screenshot)
img_width = image_util.Width(screenshot)
# Accessing pixels directly via image_util.GetPixelColor is weirdly slow,
# likely due to the underlying implementation (some numpy data type) not
# being great for random access. So, we instead get the pixels as a single
# byte array (whose pixel order is left to right, top to bottom) and
# manually calculate the offsets for each pixel ourselves. This results in
# the boundary calculation being ~13x faster.
pixel_data = image_util.Pixels(screenshot)
channels = image_util.Channels(screenshot)
# We include start/end as optional arguments as an optimization for finding
# the lower right corner. If the original image is large and the non-white
# portions are small and in the upper left (which is the most common case),
# checking every row/column for white can take a while.
def RowIsWhite(row, start=None, end=None):
row_offset = row * img_width * channels
start = start or 0
end = end or img_width
for col in range(start, end):
col_offset = col * channels
pixel_index = row_offset + col_offset
r = pixel_data[pixel_index]
g = pixel_data[pixel_index + 1]
b = pixel_data[pixel_index + 2]
if r != 255 or g != 255 or b != 255:
return False
return True
def ColumnIsWhite(column, start=None, end=None):
column_offset = column * channels
start = start or 0
end = end or img_height
for row in range(start, end):
row_offset = row * img_width * channels
pixel_index = row_offset + column_offset
r = pixel_data[pixel_index]
g = pixel_data[pixel_index + 1]
b = pixel_data[pixel_index + 2]
if r != 255 or g != 255 or b != 255:
return False
return True
x1 = y1 = 0
x2 = img_width
y2 = img_height
for column in range(img_width):
if not ColumnIsWhite(column):
x1 = column
break
else:
raise RuntimeError(
'Attempted to crop to non-white content in an all white image')
for row in range(img_height):
if not RowIsWhite(row, start=x1):
y1 = row
break
# We work from the right/bottom of the image here in case there are multiple
# things that need to be tested separated by whitespace like is the case for
# many video-related tests.
for column in range(img_width - 1, x1 - 1, -1):
if not ColumnIsWhite(column, start=y1):
x2 = column + 1
break
for row in range(img_height - 1, y1 - 1, -1):
if not RowIsWhite(row, start=x1, end=x2):
y2 = row + 1
break
return x1, y1, x2, y2