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 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240
|
# 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 telemetry.util import image_util
from gpu_tests import common_typing as ct
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: int | None, y2: int | None):
"""
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. Can be
negative to specify an offset relative to the right edge.
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. Can be
negative to specify an offset relative to the bottom.
"""
assert x1 >= 0
assert y1 >= 0
assert x2 is None or x2 > x1 or x2 < 0
assert y2 is None or y2 > y1 or y2 < 0
self._x1 = x1
self._y1 = y1
self._x2 = x2
self._y2 = y2
# pylint: disable=too-many-locals
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)
image_width = image_util.Width(screenshot)
image_height = image_util.Height(screenshot)
# When actually clamping the value, it's possible we'll catch the
# scrollbar, so account for its width in the clamp.
max_x = image_width - FixedRectCropAction.SCROLLBAR_WIDTH
max_y = image_height
if self._x2 is None:
end_x = max_x
elif self._x2 < 0:
tentative_x = max(start_x + 1, int(self._x2 * dpr) + image_width)
end_x = min(tentative_x, max_x)
else:
end_x = min(int(self._x2 * dpr), max_x)
if self._y2 is None:
end_y = max_y
elif self._y2 < 0:
tentative_y = max(start_y + 1, int(self._y2 * dpr) + image_height)
end_y = min(tentative_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)
# pylint: enable=too-many-locals
class NonWhiteContentCropAction(BaseCropAction):
"""Crops screenshots to remove all white (background) content."""
OFF_WHITE_TOP_ROW_DEVICES = {
# Samsung A13.
'SM-A137F',
# Samsung A23.
'SM-A236B',
# Chromebooks using the Brya board.
'Brya',
}
def __init__(self, initial_crop: BaseCropAction | None = 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
|