Русский
Русский
English
Статистика
Реклама

Pillow

Тестирование скриншотами

03.03.2021 12:06:10 | Автор: admin

Для будущих студентов курса Python QA Engineer подготовили авторскую статью.

Также приглашаем на открытый вебинар по теме
Непрерывная интеграция с Jenkins. Рассмотрим, как настраивать автоматический запуск тестов, устанавливать плагины и создавать бекапы конфигураций сборок.


Здравствуйте! Сегодня хочу рассказать о нашем опыте тестирования скриншотами с использованием python, selenium, и Pillow.

Зачем? У нас был довольно большой (~1000) набор тестов на стеке python, pytest, selenium, которые отлично проверяли, что кнопки кликаются, а статистика отправляется (с использованием browserup proxy), но пропускали баги типа таких:

Используя только селениум, практически невозможно обнаружить такую неполадку, пока она не случится (не будешь же для каждого элемента добавлять проверку для каждого из его стилей).

Также проблема в том, что у seleniuma трудности с определением видимости некоторых компонентов, а значит, и протестировать верстку с его помощью затруднительно.

Посмотрим, как selenium определяет видимость для элементов карусели:

Скрипт:

from selenium.webdriver import Chromefrom collections import Counterdriver = Chrome()driver.get("https://go.mail.ru/search?q=%D1%86%D0%B2%D0%B5%D1%82%D0%BE%D1%87%D0%BA%D0%B8%20%D0%BA%D0%B0%D1%80%D1%82%D0%B8%D0%BD%D0%BA%D0%B8")elements = driver.find_elements_by_css_selector(".SmackPicturesContent-smackImageItem")print(Counter([el.is_displayed() for el in elements]))driver.quit()

Напечатает Counter({True: 10}), хотя видимых элементов явно не 10, и независимо от того, какой сегмент карусели отображается, количество видимых карточек не меняется, из-за чего невозможно проверить скролл.

Аналогично будут работать и явные ожидания (visibility_of, visibility_of_all_elements_located, etc), так как они просто дергают у элемента is_displayed.

Решение

Нам был нужен инструмент для pytest и selenium, чтобы мы могли переиспользовать инфраструктуру, на которой крутились текущие тесты. После поиска выяснилось, что ничего готового нет, а то что есть давно не поддерживается. Поэтому решили сделать сами.

В общем это работает так:

  • открываем страницу селениумом, готовим ее к тесту (заполняем поля, жмем кнопки);

  • скриншотим всю страницу, и вырезаем кусок, на котором размещен компонент, который нужно протестировать;

  • вырезаем его, повторяем все на стейджинге и попиксельно сравниваем получившиеся картинки;

  • сами картинки перед сравнением нарезаем на блоки размером 40х40 пикселей, те участки которые не совпали помечаем красным прямоугольником для наглядности.

Таким образом можно протестировать и развалившуюся верстку, и компоненты страницы, для которых селениум не может корректно определить видимость.

Рабочий пример на гитхабе.

В итоге тест выглядит так:

def test_search_block(self):   self.driver.get("https://go.mail.ru/")   def action():       self.driver.find_element_by_xpath("//span[contains(text(), 'Соцсети')]").click()   self.check_by_screenshot((By.CSS_SELECTOR, ".MainPage-verticalLinksWrapper"), action=action)

В action кладем все нужные сетапы, при необходимости финализацию можно добавить аналогичным способом.

После завершения теста у нас получится три изображения с тестинга, с прода и дифф. Все три добавляем в отчет.

Благодаря плагину для аллюра отчет выглядит так:

И в случае падения:

Проблемы

  1. Антиалайзинг особенно сильно флакали тесты, где на скриншот попадали svg. Решили смазав границу между цветами:

RED = "red"GREEN = "green"BLUE = "blue"ALPHA = "alpha"# https://github.com/rsmbl/Resemble.js/blob/dec5ae1cf1d10c9027a94400a81c17d025a9d3b6/resemble.js#L121# https://github.com/rsmbl/Resemble.js/blob/dec5ae1cf1d10c9027a94400a81c17d025a9d3b6/resemble.js#L981tolerance = {   RED: 32,   GREEN: 32,   BLUE: 32,   ALPHA: 32,}
def _is_color_similar(self, a, b, color):   """Проверить схожесть цветов. Для того, чтобы тесты не тригеррились на антиалиасинг допуски   в self.tolerance.   """   if a is None and b is None:       return True   diff = abs(a - b)   if diff == 0:       return True   elif diff < self.tolerance[color]:       return True   return False

Так же сделано в Resemble.js. Надо сказать, что это диалектически влияет на надежность тестов. С одной стороны мы можем упустить проблемы с одинаковыми цветами, с другой тесты практически перестают моргать из-за сглаживания.

Для особо тяжелых случаев просто удаляем проблемный элемент со страницы, например, так:

def test_search_block(self):   self.driver.get("https://go.mail.ru/")   def action():       element = self.driver.find_element_by_xpath("//span[contains(text(), 'Соцсети')]")       self.driver.execute_script("arguments[0].remove()", element)   self.check_by_screenshot((By.CSS_SELECTOR, ".MainVerticalsNav-listItemActive"), action=action)

Примерно так же можно добавить элементу заливку, удалить изображение, и т.д.

  1. На скриншотах появлялись затухающие скроллбары. Невозможно сделать скриншоты так, чтобы момент анимации у скроллов совпал из-за этого тесты сильно флакали. В итоге помогло включение хэдлесс режима скроллбары пропали.

  2. Не актуальные эталоны проблема решилась сама собой благодаря отказу от эталонов. Заодно нет проблемы с необходимостью следить за тем, чтобы эталоны были сделаны там же, где гоняются тесты (на разных платформах разные шрифты и не только).

  3. Для мобильных тестов (андроид эмуляторы и эмуляция в хроме) нужно учитывать плотность пикселей. Так получаем размеры и координаты элемента:

def _get_raw_coords_by_locator(self, locator_type, query_string):   """Без учета плотности пикселей."""   wait = WebDriverWait(self.driver, timeout=10, ignored_exceptions=Exception)   wait.until(lambda _: self.driver.find_element(locator_type, query_string).is_displayed(),                   message="Невозможно получить размеры элемента, элемент не отображается")     el = self.driver.find_element(locator_type, query_string)   location = el.location   size = el.size   x = location["x"]   y = location["y"]   width = location["x"] + size['width']   height = location["y"] + size['height']   return x, y, width, height

А так делаем поправку на плотность пикселей:

def _get_coords_by_locator(self, locator_type, query_string) -> Tuple[int, int, int, int]:   x, y, width, height = self._get_raw_coords_by_locator(locator_type, query_string)   return x * self.pixel_ratio, y * self.pixel_ratio, width * self.pixel_ratio, height * self.pixel_ratio

Если не учесть этого, скриншотится будет все что угодно, кроме нужных элементов.

Размер элементов, которые возвращает селениум:

from selenium.webdriver import Chrome, ChromeOptionsoptions = ChromeOptions()options.add_experimental_option("mobileEmulation", {'deviceName': "Nexus 5"})options.add_argument('--headless')caps = options.to_capabilities()driver = Chrome(desired_capabilities=caps)driver.get("https://go.mail.ru/")print(driver.find_element_by_xpath("//body").size)driver.save_screenshot("test.png")driver.quit()

Напечатает: {'height': 640, 'width': 360} для боди страницы.

И в тоже время вернет скриншот размером 1080 х 1920:

 file test.pngtest.png: PNG image data, 1080 x 1920, 8-bit/color RGBA, non-interlaced

4.Тесты никогда не будут зелеными во время релиза. Любые изменения в покрытых компонентах заставят тесты покраснеть, или сделают их сравнение невозможным (если расползутся размеры на тестинге и стейджинге). У этого есть и плюс: на диффе в отчете сразу видны изменения, в том числе те, которых не ожидали.

Итог

Сейчас у нас ~570 скриншот-тестов в двух сборках (мобильная и десктопная). Каждая сборка запущена в 20 потоков, и идет около 15 минут. С учетом изменений в релизах, которых ломают тесты, флакающих не больше 2-3%. В основном тесты падают из-за регресса, или изменений в верстке. Писать их тоже достаточно легко, при необходимости проверку скриншотами можно совместить с обычными для селениум-тестов проверками (текст, кликабельность, видимость).

Полезные ссылки про тестирование скриншотами:

  1. https://blog.rinatussenov.com/automating-manual-visual-regression-tests-with-python-and-selenium-be66be950196

  2. https://www.youtube.com/watch?v=crbwyGlcXm0


Узнать подробнее о курсе Python QA Engineer.

Смотреть открытый вебинар по теме Непрерывная интеграция с Jenkins.

Подробнее..

Перегон картинок из Pillow в NumPyOpenCV всего за два копирования памяти

08.03.2021 10:09:17 | Автор: admin

Стоп, что? В смысле всего? Разве преобразование из одного формата в другой нельзя сделать за одно копирование, а лучше вообще без копирования?

Да, это кажется безумием, но более привычные методы преобразования картинок работают в 1,5-2,5 раза медленнее (если нужен не read-only объект). Сегодня я покопаюсь в кишках обеих библиотек, расскажу почему так получилось и кто виноват. А также покажу финальный результат, который работает так же, только быстрее. Никаких репозиториев или пакетов не будет, только рассказ и рабочий код в конце. Но давайте обо всём по порядку.

Pillow это библиотека для работы с изображениями на языке Python. Поддерживает разные форматы, имеет ленивую загрузку, дает доступ к метаинформации из файла. Короче делает все, что нужно для загрузки изображений.

NumPy библиотека-комбайн для работы с многомерными массивами. Базовая библиотека для целой кучи научных библиотек, библиотек компьютерного зрения и машинного обучения.

OpenCV самая популярная библиотека компьютерного зрения. Имеет огромное количество функций. Не имеет собственного внутреннего формата хранения для изображений, вместо этого использует массивы NumPy. Сценарий, когда нужно преобразовать изображение из Pillow в NumPy, чтобы дальше работать с ним с помощью OpenCV, чрезвычайно распространенный.

Для разнообразия сегодня я буду запускать бенчмарки на Raspberry Pi 4 1800 MHz под 64-разрядной Raspberry Pi OS. В конце концов, где ещё может понадобиться компьютерное зрение, как не на Малинке :-)

На случай, если вы не знаете как настроить окружение

Подключаетесь по SSH и ставите менеджер виртуального окружения:

$ sudo apt install python3-venv

Дальше sudo вам не понадобится. Создаете виртуальное окружение:

$ python3 -m venv pil_num_env

Активируете виртуальное окружение:

$ source ./pil_num_env/bin/activate

Обновляете pip:

$ pip install -U pip

Ставите всё, с чем мы будем сегодня работать:

$ pip install ipython pillow numpy opencv-python-headless

Всё готово, заходите в интерактивный интерпретатор:

$ ipython
Python 3.7.3 (default, Jul 25 2020, 13:03:44)
IPython 7.21.0 -- An enhanced Interactive Python. Type '?' for help.
In [1]:_

Как работает преобразование в NumPy

Существует два общепринятых способа конвертировать изображение Pillow в NumPy, с равной вероятностью вы нагуглите один из них:

  1. numpy.array(im) делает копию из изображения в массив NumPy.

  2. numpy.asarray(im) то же самое, что numpy.array(im, copy=False), то есть якобы не делает копию, а использует память оригинального объекта. На самом деле всё несколько сложнее.

Можно было бы подумать, что во втором случае массив NumPy становится как бы вью на оригинальное изображение, и если изменять массив NumPy, то будет меняться и изображение. На деле это не так:

In [1]: from PIL import ImageIn [2]: import numpyIn [3]: im = Image.open('./canyon.jpg').resize((4096, 4096))In [4]: n = numpy.asarray(im)In [5]: n[:, :, 0] = 255ValueError: assignment destination is read-onlyIn [6]: n.flagsOut[6]:   C_CONTIGUOUS : True  F_CONTIGUOUS : False  OWNDATA : False  WRITEABLE : False  ALIGNED : True  WRITEBACKIFCOPY : False  UPDATEIFCOPY : False

Это сильно отличается от того, что будет, если использовать функцию numpy.array():

In [7]: n = numpy.array(im)In [8]: n[:, :, 0] = 255In [9]: n.flagsOut[9]:   C_CONTIGUOUS : True  F_CONTIGUOUS : False  OWNDATA : True  WRITEABLE : True  ALIGNED : True  WRITEBACKIFCOPY : False  UPDATEIFCOPY : False

При этом, если провести измерение, функция asarray() действительноработает значительно быстрее:

In [10]: %timeit -n 10 n = numpy.array(im)257 ms  1.27 ms per loop (mean  std. dev. of 7 runs, 10 loops each)In [11]: %timeit -n 10 n = numpy.asarray(im)179 ms  786 s per loop (mean  std. dev. of 7 runs, 10 loops each)

Тут копирований явно происходит меньше, за что мы расплачиваемся невозможностью изменять массив. Но само время преобразования остается чудовищно большим по сравнению с одним копированием. Давайте разбираться, на что же оно тратится.

Интерфейс массивов NumPy

Если посмотреть на зависимости и код Pillow, там не найдется упоминаний NumPy (на самом деле найдется, но только в комментариях). То же самое верно и в обратную сторону. Как же изображения конвертируются из одного формата в другой? Оказывается, у NumPy для этого есть специальный интерфейс. Вы делаете специальное свойство у нужного объекта, в котором объясняете NumPy, как ему следует извлечь данные, а он эти данные забирает. Вот упрощенная реализация этого свойства из Pillow:

    @property    def __array_interface__(self):        shape, typestr = _conv_type_shape(self)        return {            "shape": shape,            "typestr": typestr,            "version": 3,            "data": self.tobytes(),        }

_conv_type_shape() описывает тип и размер массива, который должен получиться. А всё самое интересное происходит в методе tobytes(). Если проверить, сколько этот метод выполняется, станет понятно, что в общем-то NumPy от себя ничего не добавляет:

In [12]: %timeit -n 10 n = im.tobytes()179 ms  1.27 ms per loop (mean  std. dev. of 7 runs, 10 loops each)

Время точно совпадает с временем функции asarray(). Кажется виновник найден, осталось заменить вызов этой функции или ускорить её, и дело в шляпе, верно? Ну, не всё так просто.

Внутреннее устройство памяти в Pillow и NumPy

Устройство массивов в NumPy описывается чрезвычайно просто это непрерывный кусок памяти, начинающийся с определенного указателя. Плюс есть смещения (strides), которые задаются отдельно по каждому измерению.

В Pillow всё устроено принципиально иначе. Изображение хранится чанками, в каждом чанке находится целое количество строк изображения. Каждый пиксель занимает 1 или 4 байта (не от 1 до 4, а ровно). Соответственно, для каких-то режимов изображения какие-то байты не используются. Например, для RGB не используется последний байт в каждом пикселе, а для черно-белых изображений с альфа-каналом (режим LA) не используются два средних байта для того, чтобы альфа-канал был в последнем байте пикселя.

Всё это я рассказываю, потому что не хочу, чтобы у кого-то остались иллюзии, что можно решить вопрос как-то малой кровью, не переписывая одну или другую библиотеку.

Я думаю, теперь понятно, для чего нужен метод tobytes() он переводит внутреннее представление изображения Pillow в непрерывный поток байтов одним куском без пропусков: как раз такое, какое может использовать NumPy. NumPy уже получая на вход объект bytes, может либо сделать копию, либо использовать его в режиме read-only. Тут я не уверен, сделано ли это, чтобы нельзя было обойти неизменность объектов bytesв Python, или есть какие-то реальные ограничения на уровне C API. Но, например, если на вход вместо bytes податьbytearray, то массив не будет read-only.

Но давайте всё же посмотрим на упрощенную версию tobytes():

    def tobytes(self):        self.load()        # unpack data        e = Image._getencoder(self.mode, "raw", self.mode)        e.setimage(self.im)        data, bufsize, s = [], 65536, 0        while not s:            l, s, d = e.encode(bufsize)            data.append(d)        if s < 0:            raise RuntimeError(f"encoder error {s} in tobytes")        return b"".join(data)

Тут видно, что создается "raw"энкодер и из него получаются чанки изображения не менее 65 килобайт памяти. Это и есть первое копирование: к концу функций у нас всё изображение в виде небольших чанков лежит в массиве data. Последней строкой происходит второе копирование: все чанки собираются в одну большую байтовую строку.

Кто виноват и что делать

Напомню, что библиотеки написаны так, чтобы интерфейс был, а явного использования библиотеками друг друга не было. В таких условиях, я думаю, что это почти оптимальное решение. Но что, если у нас нет такого ограничения, а скорость хочется получить максимальную?

Первое, что хочется отметить: отказываться от энкодера не вариант. Кто знает, какие детали реализации он от нас срывает. Переносить это всё на уровень Python или переписывать часть на C последнее дело.

Кажется, намного разумнее было бы в tobytes()заранее выделить буфер нужного размера, и уже в него записывать чанки. Но очевидно, что интерфейс энкодера так не работает: он уже возвращает чанки упакованные в объекты bytes. Тем не менее, если эти чанки не складировать, а сразу копировать в буфер, эти данные не будут вымываться из L2 кэша и быстро попадут куда надо. Что-то вроде такого:

def to_mem(im):    im.load()    e = Image._getencoder(im.mode, "raw", im.mode)    e.setimage(im.im)    mem = ... # we don't know yet    bufsize, offset, s = 65536, 0, 0    while not s:        l, s, d = e.encode(bufsize)        mem[offset:offset + len(d)] = d        offset += len(d)    if s < 0:        raise RuntimeError(f"encoder error {s} in tobytes")    return mem

Что же будет вместо mem. В идеале это должен быть массив NumPy. Создать его не представляет проблем, мы уже видели какие у него будут параметры в __array_interface__:

In [13]: shape, typestr = Image._conv_type_shape(im)In [14]: data = numpy.empty(shape, dtype=numpy.dtype(typestr))

Но если попробовать вместо mem взять просто его плоскую версию, то ничего не выйдет:

In [15]: mem = data.reshape((data.size,))In [16]: mem[0:4] = b'abcd'ValueError: invalid literal for int() with base 10: b'abcd'

В данном случае кажется странным, что нельзя в массив байтов по срезу поместить байты. Но не забывайте, что, во-первых, слева могут быть не только байты, а во-вторых, библиотека называется NumPy, то есть работает с числами. К счастью, NumPy дает доступ и к непосредственной памяти массива прямо из Python. Это свойство data:

In [17]: data.dataOut[17]: <memory at 0x7f78854d68>In [18]: data.data[0] = 255NotImplementedError: sub-views are not implementedIn [19]: data.data.shapeOut[19]: (4096, 4096, 3)In [20]: data.data[0, 0, 0] = 255

Там находится объект memoryview. Вот только этот memoryviewкакой-то странный: он тоже многомерный, как и сам массив NumPy, ещё у него такой же тип объектов, как у самого массива. К счастью, это легко исправляется методом cast:

In [21]: mem = data.data.cast('B', (data.data.nbytes,))In [22]: mem.nbytes == mem.shape[0]Out[22]: TrueIn [23]: mem[0], mem[1]Out[23]: (255, 0)In [24]: mem[0:4] = b'1234'In [25]: mem[0], mem[1]Out[25]: (49, 50)

Складываем пазл вместе:

def to_numpy(im):    im.load()    # unpack data    e = Image._getencoder(im.mode, 'raw', im.mode)    e.setimage(im.im)    # NumPy buffer for the result    shape, typestr = Image._conv_type_shape(im)    data = numpy.empty(shape, dtype=numpy.dtype(typestr))    mem = data.data.cast('B', (data.data.nbytes,))    bufsize, s, offset = 65536, 0, 0    while not s:        l, s, d = e.encode(bufsize)        mem[offset:offset + len(d)] = d        offset += len(d)    if s < 0:        raise RuntimeError("encoder error %d in tobytes" % s)    return data

Проверяем:

In [26]: n = to_numpy(im)In [27]: numpy.all(n == numpy.array(im))Out[27]: TrueIn [28]: n.flagsOut[28]:   C_CONTIGUOUS : True  F_CONTIGUOUS : False  OWNDATA : True  WRITEABLE : True  ALIGNED : True  WRITEBACKIFCOPY : False  UPDATEIFCOPY : FalseIn [29]: %timeit -n 10 n = to_numpy(im)101 ms  260 s per loop (mean  std. dev. of 7 runs, 10 loops each)

Круто! Имеем ускорение в 2,5 раза с тем же функционалом и меньшее количество аллокаций.

Бенчмарки

В статье я взял достаточно большую картинку для тестов. Нет, дело не в том, что to_numpy()не дает ускорения на меньших размерах (ещё как даёт!). Дело в том, что в общем случае очень сложно добиться какого-то постоянного времени работы, когда дело касается выделения памяти. Аллокатор может затребовать новую память у системы, а может и старую сохранить. Может решить заполнить её нулями, а может и так отдать. В этом смысле работа с большими массивами хотя бы дает стабильный результат: мы всегда получаем худший случай.

Код:

In [30]: for i in range(6, 0, -1):    ...:     i = 128 * 2 ** i    ...:     print(f'\n\nSize: {i}x{i}   \t{i*i // 1024} KPx')    ...:     im = Image.new('RGB', (i, i))    ...:     print('\tnumpy.array()')    ...:     %timeit n = numpy.array(im)    ...:     print('\tnumpy.asarray()')    ...:     %timeit n = numpy.asarray(im)    ...:     print('\tto_numpy()')    ...:     %timeit n = to_numpy(im)    ...:     im = None    ...: 

Результаты:

Размер

numpy.array()

numpy.asarray()

to_numpy()

Ускорение

8192x8192

995 мс

683 мс

378 мс

2,63x

4096x4096

257

179

101

2,54x

2048x2048

24,5

13,4

10,5

2,33x

1024x1024

4,84

3,45

2,74

1,77x

512x512

1,34

1,05

0,75

1,79x

256x256

0,26

0,2

0,18

1,44x

Итого, получилось избавиться от лишней аллокации памяти, ускорить работу от 1,5 до 2,5 раз, попутно немного разобраться как NumPy работает с памятью.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru