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

Opencv

Перевод Создание камеры-ловушки с использованием Raspberry Pi, Python, OpenCV и TensorFlow

26.10.2020 16:12:51 | Автор: admin


Я сильно верю в обучение через практику, через создание чего-то нового. А для того чтобы что-то создавать, нужно чтобы работа приносила бы удовольствие.

Я начну рассказ о моём новом проекте с того, что раскрою причины, по которым решил попытаться создать камеру-ловушку на основе Raspberry Pi.

Я живу в Лондоне, мой сад часто посещают местные представители дикой природы. Случается это так часто, что я, заядлый садовник, мало-помалу начал расстраиваться. Разбитые горшки, выкопанные из земли растения, съеденные фрукты и овощи

Я видел в своём саду маленьких лис (они просто прелесть), больших лис, кошек (не моих), птиц. А однажды меня даже посетил ястреб-перепелятник.

Ястреб-перепелятник

Кто ещё заберётся в мой сад под покровом ночи?


Разглядеть животное на этом снимке не так уж и просто

Какие ещё поводы мне нужны для того чтобы создать камеру-ловушку на основе Raspberry Pi, Python, TensorFlow и чего угодно ещё? И камера у меня должна получиться очень хорошая.

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

Идея это здравая, но это будет и вполовину не так интересно.

Модули камеры для Raspberry Pi


Я начал с изучения вопроса о том, какие типы камер можно подключить к одноплатному компьютеру Raspberry Pi.

Самые популярные подобные камеры это те, которые напрямую подключаются к Raspberry Pi с использованием MIPI-коннектора. Преимущества камер такого типа заключается в том, что данные между камерой и платой передаются очень быстро. Существует и простой в использовании API для работы с камерой.

Я попробовал этот API и выяснил, что в нём имеется множество полезных функций, которые позволяют записывать видео и во время записи делать снимки. Благодаря этому я могу, используя простые и понятные инструменты, одновременно обнаруживать движение и записывать видео.

Есть три вида модуля камеры для Raspberry Pi. Ниже приведён сокращённый вариант таблицы с характеристиками таких модулей.
Camera Module v1 Camera Module v2 HQ Camera
Цена нетто $25 $25 $50
Размер Около 25 24 9 мм 38 x 38 x 18.4 мм (не включая оптику)
Вес 3 г 3 г
Разрешение снимков 5 М 8 М 12.3 М
Видеорежимы 1080p30, 720p60 и 640 480p60/90 1080p30, 720p60 и 640 480p60/90 1080p30, 720p60 и 640 480p60/90
Интеграция с Linux Драйвер V4L2 Драйвер V4L2 Драйвер V4L2
C-API OpenMAX IL и другие OpenMAX IL и другие
Сенсор OmniVision OV5647 Sony IMX219 Sony IMX477
Разрешение сенсора 2592 1944 пикселей 3280 2464 пикселей 4056 x 3040 пикселей
Размер рабочей области сенсора 3,76 2,74 мм 3,68 x 2,76 мм (диагональ 4,6 мм) 6,287 x 4,712 мм (диагональ 7,9 мм)

В этой таблице приведены сведения об официальных модулях камеры, но можно найти камеры сторонних производителей, которые будут работать с Raspberry Pi. Модули камеры используют для обработки данных с сенсора GPU Raspberry Pi.

Так как при использовании модуля камеры обработкой изображений занимается сам Raspberry Pi, оказывается, что выбор сенсоров камер весьма ограничен. У каждого сенсора имеется собственный API, поэтому поддержка различных типов сенсоров непростая задача.

Запись видео в условиях низкой освещённости


Я собираюсь создать камеру-ловушку для наблюдения за дикими животными. Поэтому её возможностей должно быть достаточно и для работы днём, и для работы ночью. То есть, в ней должен быть сенсор, способный записывать видео в условиях низкой освещённости. Весьма желательно, чтобы он передавал бы реальный цвет снимаемых объектов. В плохих условиях освещённости Camera Module v1 и v2 работают не очень хорошо. Для того чтобы с их помощью можно было бы в таких условиях что-нибудь снять, нужно использовать ИК-подсветку и убрать из них ИК-фильтр. Процедура подготовки камеры к съёмкам в плохих условиях освещённости зависит от конкретной модели камеры. Но тут появляется ещё одна проблема, которая заключается в том, что получаемые изображения имеют розовый оттенок. При использовании подобных камер нужен механизм, который задействует ИК-фильтр при съёмках днём и убирает этот фильтр при съёмках ночью.


Изображение с камеры для Raspberry Pi, снятое днём в плохих условиях освещённости

Но существует новая камера для Raspberry Pi, которая в таблице обозначена как HQ Camera. Правда, я не вполне уверен в её ночных возможностях. Она основана на сенсоре Sony IMX477, от которого в плохих условиях освещённости можно ожидать получения более качественной картинки, чем могут выдать камеры предыдущих поколений. Может ли этот сенсор выдавать правильную цветную картинку в темноте, я ещё собираюсь выяснить. Но мои предварительные исследования камер, касающиеся их спецификаций, говорят о том, что вряд ли он на это способен.

Sony Starvis замечательный сенсор для камер


У компании Sony есть особое семейство сенсоров для камер, используемых, в основном, для видеонаблюдения. Это Sony Starvis. Эти сенсоры представляют собой крайне продвинутые устройства, способные давать качественное цветное изображение в условиях низкой освещённости при всего лишь 0,001 лк. Люкс (лк) это единица измерения освещённости.

Для того чтобы было понятнее, приведу несколько примеров. Практически полная темнота это 0,0001 лк. А именно: нет солнечного света, нет света луны и звёзд, небо затянуто тучами, нет искусственных источников освещения. Мне неизвестны сенсоры для камер, способные снимать в полной темноте.

Но если небо чистое, то звёзды дают освещённость в 0,002 лк. Хотя и в таких условиях всё ещё очень темно, сенсор Sony Starvis способен снимать при освещённости, в два раза меньше этой. Как по мне, так это просто потрясающе.

Вот страница из Википедии, где можно найти дополнительные примеры.

Будем надеяться, что я смог донести до вас идею о том, что Sony Starvis это идеальный сенсор для камеры-ловушки.

Главный минус этого сенсора в том, что нет камер для Raspberry Pi, в которых он применяется. Но если бы мне попалась USB-камера или IP-камера с таким сенсором, я бы что-нибудь придумал и подключил бы её к Raspberry Pi.

Собственно говоря, я нашёл такую камеру.

Я не хотел слишком сильно вкладываться в этот проект, поэтому купил подходящую IP-камеру с Sony Starvis на Aliexpress. Эта покупка обошлась мне, если я всё правильно помню, в 20.

Сравнивать камеру для Raspberry Pi и эту камеру это как сравнивать день и ночь. И я ничуть не преувеличиваю. Да вот сами посмотрите.


Камера с сенсором Sony Starvis IMX307, съёмка в тёмной комнате


Камера Raspberry Pi v2 та же комната, но другая точка съёмки

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

Кто-то может сказать, что запись цветного видео в плохих условиях освещённости это не так уж и важно, что можно обойтись простой камерой с ИК-подсветкой. Но я не могу использовать ИК-подсветку, так как собираюсь разместить камеру в помещении и направить её на сад через окно. А если направить источник ИК-излучения на стекло, стекло поведёт себя как зеркало и камера ослепнет.

Если же говорить о камерах для Raspberry Pi, то они хорошо подходят для каких-нибудь простых проектов или для изучения чего-то нового. Но если заняться каким-нибудь хотя бы наполовину серьёзным проектом, тогда просто необходимо пользоваться более качественными камерами.

Применение USB-камеры (или даже IP-камеры) открывает совершенно новые возможности. Кроме того, если у вас при этом есть ещё и обычная камера для Raspberry Pi, вы можете употребить её для каких-нибудь экспериментов из сферы искусственного интеллекта.

Установка и настройка камеры для Raspberry Pi


Займёмся подключением Camera Module v2 к плате. Это, на самом деле, очень просто.


Camera Module v2

У камеры имеется сине-белый шлейф. Его нужно подключить к CSI-коннектору платы. Синяя сторона шлейфа должна быть обращена к задней части платы.

Я использовал корпус для камеры, напечатанный на 3D-принтере. Соответствующие файлы я нашёл на Thingiverse. Но подходящий корпус, совсем недорогой, можно найти и, например, на Amazon.


Корпус для камеры

Теперь пришло время включить камеру.

После включения Raspberry Pi нужно открыть окно терминала.


Терминал

Затем надо выполнить такую команду:

$ sudo apt update

А потом такую:

$ sudo apt full upgrade

Это делается для того чтобы обеспечить использование на плате последней версии Raspbian и самых свежих патчей и обновлений.

После этого надо выполнить в терминале следующую команду:

sudo raspi-config


Работа с raspi-config

Здесь нас интересует раздел Interfacing Options>P1 Camera. Потом надо выбрать команду Finish и перезагрузить Raspberry Pi.

Съёмка фотографий с использованием raspistill


Теперь камера должна быть готова к работе. Проверим её с помощью raspistill. Снова откроем терминал и введём там такую команду:

raspistill -v -o test.jpg

Вот какое чудесное фото сняла моя камера.


Снимок с камеры для Raspberry Pi

Запись видео с помощью raspivid


Фотографии это хорошо, но запись видео это уже гораздо лучше. Тут нам на помощь придёт raspivid:

raspivid -o vid.h264

Такая команда позволяет записать видео длительностью 5 секунд.

Если нужно снять более длительное видео этой команде понадобится передать параметр -t с указанием длительности видео в миллисекундах. Например, следующая команда позволяет записать видео длительностью в 30 секунд:

raspivid -o vid.h264 -t 30000

Настройка потокового вещания


А теперь самое интересное. Для того чтобы видеть то же самое, что видит камера, можно воспользоваться такой командой:

raspivid -o - -t 0 -n | cvlc -vvv stream:///dev/stdin --sout '#rtp{sdp=rtsp://:8554/}' :demux=h264

Она создаёт RTSP-поток, подключиться к которому можно из локальной сети.

Итоги


Теперь, когда я разобрался с камерами для Raspberry Pi, можно развивать проект дальше, а именно, устанавливать на Raspberry Pi 4 TensorFlow, Open CV и Python и приступать к написанию кода. Об этом я планирую рассказать в следующих моих материалах. Вот, кому интересно, мой канал на YouTube, там вы можете найти видео, связанные с этим проектом.

Работали ли вы с камерами для Raspberry Pi?



Подробнее..

Нейросеть для раскрутки собачьего аккаунта в Инстаграм или робопёс в действии

17.01.2021 22:08:31 | Автор: admin

Механика

Скрипт для раскрутки обзовём для краткости "робопёс". Представим, что этот скрипт от лица собачьего аккаунта периодически собирает посты по хэштегам на собачьи темы и ставит лайки таким постам. Какая-то часть авторов этих постов заинтресуется, кто им поставил лайк, и зайдёт на страницу собачьего аккаунта. Ну а дальше, как пойдёт. Большинство просто этого не заметят. Кто-то пролистает пару экранов вниз и пойдёт дальше. Кто-то поставит робопсу пару ответных лайков (что тоже неплохо, лайки увеличивают охват постов). А если аккаунт понравится (что не исключено, ибо пёс весьма харизматичен), может и подписаться.

Осторожно: "масслайкинг"

Надо отметить, что такие действия называются "масслайкинг" и не особо привествуются Instagram. При появлении подозрительной робопёсей активности Instagram сначал вынесет несколько предупреждений, а потом может и навечно забанить аккаунт. Кстати, под запрет попадает и ручной масслайкинг (т.е. если лайкает не автоматизированный сервис, а лично Вы).

Есть мнение, что масслайкинг сейчас не особо работает. Ну что же, заодно и проверим.

Сервисы и библиотеки для масслайкинга и массфоловинга

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

Получается, как с сексом. Вроде все занимаются, но обсуждать в приличном обществе не принято. Опять же в интернетах можно найти эмпирически установленные ограничения для масслайкинга. Если робопёс не будет их превышать, то в бан его отправить не должны. Для молодых (менее полугода от даты регистрации) аккаунтов это не более 30 лайков в час или 720 в день.

Для питонистов есть специальные библиотеки. Наиболее известная - Instapy (12 тыс. звезд на Github, на минуточку). Есть менее известные. Кстати, недавно на Хабре была статья в которой разбирается очень даже достойная библиотека instabot. Но использовать их "в лоб" для автойлакинга по хэштегам лично я бы не стал. По нижеследующей причине.

Что не так с хэштегами

Проблема с высокачастотными хэштегами состоит в том, что они сильно зашумлены разным спамом (с которым Instagram тоже борется, но явно не особо успешно).

Вы видите пса, собаку или собачку? А они есть...Вы видите пса, собаку или собачку? А они есть...

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

Робопёс не хочет лайкать дома, а готов дарить лайки исключтельно лайкам (и всем прочим породам собак), но никак не домам! А значит нужны какие-то дополнительные инструменты для отсеивания ненужного контента.

Анализ фото постов с помощью нейросети

По идее "левые" фотки к постам можно отсечь с помощью анализа изображений нейросетью. То есть робопёс должен скачивать картинку поста, и определять, есть на ней собака. Если есть - лайкать, если нет - пропускать. Такая задача называется Object Detection, для её решения есть специальные инструменты, а именно SSD детекторы.

На заглавной картинке к посту - пара собачьих фото, по которым прошелся SSD детектор на основе MobileNet v.2, обученный на датасэте COCO2017. Робопёс будет использовать архитектуру MobileNet, поскольку она мало весит и быстро работает и на обычной машине без GPU с приемлемой точностью. Кстати, на обоих фото собака определена нейросетью с вероятностью 94%.

Для желающих самостоятельно потестить, как работает SSD на произвольном посте выкладываю здесь кусок кода на python. Для удобства выложил скрипт на Github, но файл с весами не влез, придётся скачивать отдельно, в репозиории есть инструкция.

Код
import cv2import jsonfrom datetime import datetimeimport requestsimport numpy as npimport pandas as pdfrom matplotlib import pyplot as pltdef id_class_name(class_id, classes):    for key, value in classes.items():        if class_id == key:            return value# Здесь вбиваем нужный постshortcode = 'CJ.........'classNames = {}with open('models/coco2017_labels.txt', 'r+', encoding='utf-8') as file:    for line in file:        key = int(line.split(':')[0])        value = line.split(':')[1]        classNames[key] = value.strip()        COLORS = np.random.uniform(0, 255, size=(len(classNames), 3))s = requests.session()r = s.get(f'https://www.instagram.com/p/{shortcode}/?__a=1', headers = {'User-agent': 'bot'})url = r.json()['graphql']['shortcode_media']['display_resources'][0]['src']resp = requests.get(url, stream=True)image = np.asarray(bytearray(resp.content), dtype="uint8")image = cv2.imdecode(image, cv2.IMREAD_COLOR)image_height, image_width, _ = image.shapeframe_resized = cv2.resize(image,(300,300))model = cv2.dnn.readNetFromTensorflow('models/frozen_inference_graphод.pb',                                      'models/ssd_mobilenet_v2_coco_2018_03_29.pbtxt')model.setInput(cv2.dnn.blobFromImage(frame_resized, size=(300, 300), swapRB=True))output = model.forward()detections = output[0, 0, :, :]detections = detections[detections[:,2].argsort()]for detection in detections:    confidence = detection[2]    class_id = int(detection[1])    class_name = id_class_name(class_id, classNames)    if (confidence > 0.3):        box_x =      int(detection[3] * image_width)        box_y =      int(detection[4] * image_height)        box_width =  int(detection[5] * image_width)        box_height = int(detection[6] * image_height)        cv2.rectangle(image, (box_x, box_y), (box_width, box_height), COLORS[class_id], thickness=2)        label = class_name + ": " + str(round(confidence, 2))        labelSize, baseLine = cv2.getTextSize(label, cv2.FONT_HERSHEY_DUPLEX, 0.5, 1)        yLeftBottom_ = max(box_y, labelSize[1])        cv2.rectangle(image, (box_x, box_y + labelSize[1]), (box_x + labelSize[0], box_y), COLORS[class_id], cv2.FILLED)        cv2.putText(image, label, (box_x, box_y + labelSize[1] - baseLine//2), cv2.FONT_HERSHEY_DUPLEX, 0.5, (255, 255, 255))plt.figure(figsize=(8,8))plt.axis("off")plt.imshow(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))# Если хотите сохранить файл с обнаруженными классами в виде файла - раскомментируйте строку ниже#plt.savefig(f'{shortcode}.png')

Для запуска ещё потребуется установить библиотеку OpenCV и несколько других. Все они импортируются в начале скрипта, если чего-то не будет хватать, доставьте через pip или conda install.

Для скачивания информации о посте используется запрос вида /?__a=1 к OpenAPI Instagram. Пока этот запрос работает без авторизации, но инста каждый день чего-нибудь да закручивает, так что наверное, скоро и его закроют. Больше о структуре данных, которую использует Instagram, можно опять же почерпнуть здесь.

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

Почему собаки не летают как птицы?Почему собаки не летают как птицы?

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

Продолжение следует. В ближайшее время разверну робопса на локальной машине поавтолайкаю и пособираю статистику по отклику на лайки.

Подробнее..

Распознавание текста на картинке с помощью tesseract на Kotlin

11.09.2020 10:13:26 | Автор: admin

Ни для кого не секрет, что Python прочно занял первенство в ML и Data Science. А что если посмотреть на другие языки и платформы? Насколько в них удобно делать аналогичные решения?


К примеру, распознавание текста на картинке.


Среди текущих решений одним из наиболее распространённым инструментом является tesseract. В Python для него существует удобная библиотека, а для первоначальной обработки изображений, как правило, используется OpenCV. Для обоих этих инструментов есть исходные C++ библиотеки, поэтому их также возможно вызывать и из других экосистем. Попробуем это сделать в jvm и, в частности, на Kotlin.


Несколько слов о Kotlin. У него есть много удобных вещей для Data Science. В совокупности с экосистемой jvm получается статически типизированный Python на jvm. А не так давно ещё появилась возможность использовать Kotlin вместе с Apache Spark.

Первым делом установим tesseract. Его нужно установить отдельно на систему, согласно описанию из wiki. После установки можно проверить, что tesseract работает следующим образом:


tesseract input_file.jpg stdout -l eng --tessdata-dir /usr/local/share/tessdata/

Где --tessdata-dir путь до файлов tesseract (/usr/local/share/tessdata/ в macos). В случае успешной установки в stdout будет выведен распознанные текст.


После этого можно подключить tesseract в jvm и сравнить результат работы с нативным вызовом. Для этого подключим библиотеку:


implementation("net.sourceforge.tess4j:tess4j:4.5.3")

Для тех, кто не очень хорошо знаком с экосистемой jvm, есть лёгкий способ быстро себе всё настроить. Понадобится только установленная Java 13+. Её проще всего поставить через sdkman. Далее для удобства можно скачать Intellij IDEA, подойдёт и Community version. Основу проекта можно создать из IDE (new project -> Kotlin, gradle Kotlin) или можно клонировать репозиторий github, в котором перейти на ветку start.

После подключения библиотеки доступ к tesseract становится простым. Вызов той же команды из примера выше будет выглядеть следующим образом:


 val api = Tesseract() api.setDatapath("/usr/local/share/tessdata/") api.setLanguage("eng") val image = ImageIO.read(File("input_file.jpg")) val result: String = api.doOCR(image)

Как видно, практически все команды совпадают с используемыми в вызове из командной строки. Но, как минимум, на macos нужно ещё дополнительно настроить системную переменную jna.library.path, в которую нужно добавить путь до dylib-библиотеки tesseract.


val libPath = "/usr/local/lib"val libTess = File(libPath, "libtesseract.dylib")if (libTess.exists()) {    val jnaLibPath = System.getProperty("jna.library.path")    if (jnaLibPath == null) {        System.setProperty("jna.library.path", libPath)    } else {        System.setProperty("jna.library.path", libPath + File.pathSeparator + jnaLibPath)    }}

После всех настроек можно попробовать запустить распознавание для того же файла и результат должен полностью соответствовать вызову из командной строки.


Перейдём теперь к обработке изображений с OpenCV. В Python для работы с ней не требуется ставить каких-либо дополнительных инструментов, кроме пакета в pip. В описании OpenCV под java указан порядок установки, когда всё ставится отдельно. Для самой jvm-экосистемы подход, когда требуются установки каких-либо нативных библиотек, не совсем привычен. Чаще всего если зависимости требуется какие-либо дополнительные библиотеки, то либо она сама их скачивает (как, например, djl-pytorch), либо при подключении через систему сборки внутри себя уже содержит библиотеки под различные операционные системы. К счастью, для OpenCV есть такая сборка, которой и воспользуемся:


implementation("org.openpnp:opencv:4.3.0-2")

Перед началом работы с OpenCV потребуется подгрузить нативные библиотеки через:


nu.pattern.OpenCV.loadLocally()

После чего можно использовать все доступные инструменты. Как, например, конвертация изображения в чёрно-белый цвет:


 Imgproc.cvtColor(mat, mat, Imgproc.COLOR_BGR2GRAY)

Как вы уже обратили внимание, аргументом для OpenCV выступает Mat, который представляет из себя основной класс-обёртку вокруг изображения в OpenCV в jvm, похожий на привычный BufferedImage.


Сам экземпляр Mat можно получить привычным для Python кода вызовом imread:


val mat = Imgcodecs.imread("input.jpg")

В таком виде экземпляр можно дальше передавать в OpenCV и проделывать с ним различные манипуляции. Но для Java общепринятым является BufferedImage, вокруг которого, как правило, уже может быть выстроен pipeline загрузки и обработки изображения. В связи с чем возникает необходимость конвертации BufferedImage в Mat:


val image: BufferedImage = ...val pixels = (image.raster.dataBuffer as DataBufferByte).dataval mat = Mat(image.height, image.width, CvType.CV_8UC3)            .apply { put(0, 0, pixels) }

И обратной конвертации Mat в BufferedImage:


val mat = ...var type = BufferedImage.TYPE_BYTE_GRAYif (mat.channels() > 1) {    type = BufferedImage.TYPE_3BYTE_BGR}val bufferSize = mat.channels() * mat.cols() * mat.rows()val b = ByteArray(bufferSize)mat[0, 0, b] // get all the pixelsval image = BufferedImage(mat.cols(), mat.rows(), type)val targetPixels = (image.raster.dataBuffer as DataBufferByte).dataSystem.arraycopy(b, 0, targetPixels, 0, b.size)

В частности, тот же tesseract в методе doOCR поддерживает как файл, так и BufferedImage. Используя вышеописанные преобразования, можно вначале обработать изображения с помощью OpenCV, преобразовать Mat в Bufferedimage и передать подготовленное изображение на вход tesseract.


Попробуем теперь на практике собрать рабочий вариант приложения, который сможет найти текст на следующей картинке:



Для начала проверим результат нахождения текста на изображении без обработки. И вместо метода doOCR будем использовать getWords, чтобы получить ещё confidence (score в Python-библиотеке) для каждого найденного слова:


val image = ImageIO.read(URL("http://img.ifcdn.com/images/b313c1f095336b6d681f75888f8932fc8a531eacd4bc436e4d4aeff7b599b600_1.jpg"))val result = api.getWords(preparedImage, ITessAPI.TessPageIteratorLevel.RIL_WORD)

В результате будет найден только разный мусор:


[ie, [Confidence: 2.014679 Bounding box: 100 0 13 14], bad [Confidence: 61.585358 Bounding box: 202 0 11 14], oy [Confidence: 24.619446 Bounding box: 21 68 18 22], ' [Confidence: 4.998787 Bounding box: 185 40 11 18], | [Confidence: 60.889648 Bounding box: 315 62 4 14], ae. [Confidence: 27.592728 Bounding box: 0 129 320 126], c [Confidence: 0.000000 Bounding box: 74 301 3 2], ai [Confidence: 24.988930 Bounding box: 133 283 41 11], ee [Confidence: 27.483231 Bounding box: 186 283 126 41]]

Если внимательней посмотреть на изображение, то можно увидеть, что шрифт для текста белый, а значит, можно попробовать использовать threshold вместе с последующей инверсией, чтобы оставить текст только на картинке:


Пробуем следующие преобразования:


// convert to grayImgproc.cvtColor(mat, mat, Imgproc.COLOR_BGR2GRAY)// text -> white, other -> blackImgproc.threshold(mat, mat, 244.0, 255.0, Imgproc.THRESH_BINARY)// inverse Core.bitwise_not(mat, mat)

После них посмотрим на картинку в результате (которую можно сохранить в файл через Imgcodecs.imwrite("output.jpg", mat) )



Теперь если посмотреть на результаты вызова getWords, то получим следующее:


[WHEN [Confidence: 94.933418 Bounding box: 48 251 52 14], SHE [Confidence: 95.249252 Bounding box: 109 251 34 15], CATCHES [Confidence: 95.973259 Bounding box: 151 251 80 15], YOU [Confidence: 96.446579 Bounding box: 238 251 33 15], CHEATING [Confidence: 96.458656 Bounding box: 117 278 86 15]]

Как видно, весь текст успешно распознался.


Итоговый код по обработке изображения будет выглядеть следующим образом:


import net.sourceforge.tess4j.ITessAPIimport net.sourceforge.tess4j.Tesseractimport nu.pattern.OpenCVimport org.opencv.core.Coreimport org.opencv.core.CvTypeimport org.opencv.core.Matimport org.opencv.imgproc.Imgprocimport java.awt.image.BufferedImageimport java.awt.image.DataBufferByteimport java.io.Fileimport java.net.URLimport javax.imageio.ImageIOfun main() {    setupOpenCV()    setupTesseract()    val image = ImageIO.read(URL("http://img.ifcdn.com/images/b313c1f095336b6d681f75888f8932fc8a531eacd4bc436e4d4aeff7b599b600_1.jpg"))    val mat = image.toMat()    Imgproc.cvtColor(mat, mat, Imgproc.COLOR_BGR2GRAY)    Imgproc.threshold(mat, mat, 244.0, 255.0, Imgproc.THRESH_BINARY)    Core.bitwise_not(mat, mat)    val preparedImage = mat.toBufferedImage()    val api = Tesseract()    api.setDatapath("/usr/local/share/tessdata/")    api.setLanguage("eng")    val result = api.getWords(preparedImage, ITessAPI.TessPageIteratorLevel.RIL_WORD)    println(result)}private fun setupTesseract() {    val libPath = "/usr/local/lib"    val libTess = File(libPath, "libtesseract.dylib")    if (libTess.exists()) {        val jnaLibPath = System.getProperty("jna.library.path")        if (jnaLibPath == null) {            System.setProperty("jna.library.path", libPath)        } else {            System.setProperty("jna.library.path", libPath + File.pathSeparator + jnaLibPath)        }    }}private fun setupOpenCV() {    OpenCV.loadLocally()}private fun BufferedImage.toMat(): Mat {    val pixels = (raster.dataBuffer as DataBufferByte).data    return Mat(height, width, CvType.CV_8UC3)        .apply { put(0, 0, pixels) }}private fun Mat.toBufferedImage(): BufferedImage {    var type = BufferedImage.TYPE_BYTE_GRAY    if (channels() > 1) {        type = BufferedImage.TYPE_3BYTE_BGR    }    val bufferSize = channels() * cols() * rows()    val b = ByteArray(bufferSize)    this[0, 0, b] // get all the pixels    val image = BufferedImage(cols(), rows(), type)    val targetPixels = (image.raster.dataBuffer as DataBufferByte).data    System.arraycopy(b, 0, targetPixels, 0, b.size)    return image}

Если сравнить полученный код с Python-версией, то разница будет минимальная. Производительность тоже должна быть практически сравнимой (за исключением, быть может, чуть больших преобразований изображения между Mat и BufferedImage).


Преимущество Python в рамках текущего примера будет только в бесшовной передаче изображений между OpenCV и tesseract. Экосистема Python сама по себе удобна тем, что все библиотеки общаются одними и теми же типами.


В jvm-экосистеме тоже есть свои преимущества. Это и статическая типизация, и многопоточность, и общая скорость работы вместе с наличием огромного количества инструментов под любые требования. Может, текущий пример не сильно раскрывает все преимущества, но, как минимум, он демонстрирует, что для данной задачи решение на jvm и Kotlin получается ничуть не сложнее.


Python на текущий момент, беcспорно, лидер в ML. И в первую очередь все инструменты и библиотеки появляются на нём. Тем не менее, в других экосистемах можно использовать те же инструменты. Особенно учитывая, что если что-то есть под Python, то должна быть и нативная библиотека, которую можно легко подключить.


Надеюсь, что в этой статье вы нашли для себя что-нибудь полезное и новое. Спасибо за внимание, и напоследок несколько полезных ссылок:


https://djl.ai/ Deep Learning на jvm, где можно подключать модели из pytorch и tensorflow
https://deeplearning4j.org/ аналогичное решение с возможностью обучать модели и импортировать существующие на tensorflow и keras
https://kotlinlang.org/docs/reference/data-science-overview.html разные полезные вещи по Data Science на Kotlin (и Java)


Весь код доступен в репозитории https://github.com/evgzakharov/kotlin_tesseract.

Подробнее..

Из песочницы Пишем бот для пазл игры на Python

28.10.2020 12:04:04 | Автор: admin
Давно хотел попробовать свои силы в компьютерном зрении и вот этот момент настал. Интереснее обучаться на играх, поэтому тренироваться будем на боте. В статье я попытаюсь подробно расписать процесс автоматизации игры при помощи связки Python + OpenCV.

image


Ищем цель


Идем на тематический сайт miniclip.com и ищем цель. Выбор пал на цветовую головоломку Coloruid 2 раздела Puzzles, в которой нам необходимо заполнить круглое игровое поле одним цветом за заданное количество ходов.

Выбранным внизу экрана цветом заливается произвольная область, при этом соседние области одного цвета сливаются в единую.

image

Подготовка


Использовать будем Python. Бот создан исключительно в образовательных целях. Статья рассчитана на новичков в компьютером зрении, каким я сам и являюсь.

Игра находится тут
GitHub бота тут

Для работы бота нам понадобятся следующие модули:

  • opencv-python
  • Pillow
  • selenium

Бот написан и протестирован для версии Python 3.8 на Ubuntu 20.04.1. Устанавливаем необходимые модули в ваше виртуальное окружение или через pip install. Дополнительно для работы Selenium нам понадобится geckodriver для FireFox, скачать можно тут github.com/mozilla/geckodriver/releases

Управление браузером


Мы имеем дело с онлайн-игрой, поэтому для начала организуем взаимодействие с браузером. Для этой цели будем использовать Selenium, который предоставит нам API для управления FireFox. Изучаем код страницы игры. Пазл представляет из себя canvas, которая в свою очередь располагается в iframe.

Ожидаем загрузки фрейма с id = iframe-game и переключаем контекст драйвера на него. Затем ждем canvas. Она единственная во фрейме и доступна по XPath /html/body/canvas.

wait(self.__driver, 20).until(EC.frame_to_be_available_and_switch_to_it((By.ID, "iframe-game")))self.__canvas = wait(self.__driver, 20).until(EC.visibility_of_element_located((By.XPATH, "/html/body/canvas")))

Далее наша канва будет доступна через свойство self.__canvas. Вся логика работы с браузером сводится к получению скриншота canvas и клику по ней в заданной координате.

Полный код Browser.py:

from selenium import webdriverfrom selenium.webdriver.support import expected_conditions as ECfrom selenium.webdriver.support.ui import WebDriverWait as waitfrom selenium.webdriver.common.by import Byclass Browser:    def __init__(self, game_url):        self.__driver = webdriver.Firefox()        self.__driver.get(game_url)        wait(self.__driver, 20).until(EC.frame_to_be_available_and_switch_to_it((By.ID, "iframe-game")))        self.__canvas = wait(self.__driver, 20).until(EC.visibility_of_element_located((By.XPATH, "/html/body/canvas")))    def screenshot(self):        return self.__canvas.screenshot_as_png    def quit(self):        self.__driver.quit()    def click(self, click_point):        action = webdriver.common.action_chains.ActionChains(self.__driver)        action.move_to_element_with_offset(self.__canvas, click_point[0], click_point[1]).click().perform()

Состояния игры


Приступим к самой игре. Вся логика бота будет реализована в классе Robot. Разделим игровой процесс на 7 состояний и назначим им методы для их обработки. Выделим отдельно обучающий уровень. Он содержит большой белый курсор, указывающий куда нажимать, который не позволит корректно распознавать элементы игры.

  • Приветственный экран
  • Экран выбора уровня
  • Выбор цвета на обучающем уровне
  • Выбор области на обучающем уровне
  • Выбор цвета
  • Выбор области
  • Результат хода

class Robot:    STATE_START = 0x01    STATE_SELECT_LEVEL = 0x02    STATE_TRAINING_SELECT_COLOR = 0x03    STATE_TRAINING_SELECT_AREA = 0x04    STATE_GAME_SELECT_COLOR = 0x05    STATE_GAME_SELECT_AREA = 0x06    STATE_GAME_RESULT = 0x07    def __init__(self):        self.states = {            self.STATE_START: self.state_start,            self.STATE_SELECT_LEVEL: self.state_select_level,            self.STATE_TRAINING_SELECT_COLOR: self.state_training_select_color,            self.STATE_TRAINING_SELECT_AREA: self.state_training_select_area,            self.STATE_GAME_RESULT: self.state_game_result,            self.STATE_GAME_SELECT_COLOR: self.state_game_select_color,            self.STATE_GAME_SELECT_AREA: self.state_game_select_area,        }

Для большей стабильности бота будем проверять, успешно ли произошла смена игрового состояния. Если self.state_next_success_condition не вернет True за время self.state_timeout продолжаем обрабатывать текущее состояние, иначе переключаемся на self.state_next. Также переведем скриншот, полученный от Selenium, в понятный для OpenCV формат.

import timeimport cv2import numpyfrom PIL import Imagefrom io import BytesIOclass Robot:    def __init__(self):# self.screenshot = []        self.state_next_success_condition = None          self.state_start_time = 0          self.state_timeout = 0         self.state_current = 0         self.state_next = 0      def run(self, screenshot):        self.screenshot = cv2.cvtColor(numpy.array(Image.open(BytesIO(screenshot))), cv2.COLOR_BGR2RGB)        if self.state_current != self.state_next:            if self.state_next_success_condition():                self.set_state_current()            elif time.time() - self.state_start_time >= self.state_timeout                    self.state_next = self.state_current            return False        else:            try:                return self.states[self.state_current]()            except KeyError:                self.__del__()    def set_state_current(self):        self.state_current = self.state_next    def set_state_next(self, state_next, state_next_success_condition, state_timeout):        self.state_next_success_condition = state_next_success_condition        self.state_start_time = time.time()        self.state_timeout = state_timeout        self.state_next = state_next

Реализуем проверку в методах обработки состояний. Ждем кнопку Play на стартовом экране и кликаем по ней. Если в течении 10 секунд мы не получили экран выбора уровней, возвращаемся к предыдущему этапу self.STATE_START, иначе переходим к обработке self.STATE_SELECT_LEVEL.

# class Robot:   DEFAULT_STATE_TIMEOUT = 10      #     def state_start(self):        # пытаемся получить координату кнопки Play        #         if button_play is False:            return False        self.set_state_next(self.STATE_SELECT_LEVEL, self.state_select_level_condition, self.DEFAULT_STATE_TIMEOUT)        return button_play    def state_select_level_condition(self):        # содержит ли скриншот выбор уровней# 

Зрение бота


Пороговая обработка изображения


Определим цвета, которые используются в игре. Это 5 игровых цветов и цвет курсора на учебном уровне. COLOR_ALL будем использовать, если нужно найти все объекты, независимо от цвета. Для начала этот случай мы и рассмотрим.

    COLOR_BLUE = 0x01      COLOR_ORANGE = 0x02    COLOR_RED = 0x03    COLOR_GREEN = 0x04    COLOR_YELLOW = 0x05    COLOR_WHITE = 0x06    COLOR_ALL = 0x07

Для поиска объекта в первую очередь необходимо упростить изображение. Для примера возьмем символ 0 и применим к нему пороговую обработку, то есть отделим объект от фона. На этом этапе нам не важно, какого цвета символ. Для начала переведем изображение в черно-белое, сделав его 1-канальным. В этом нам поможет функция cv2.cvtColor со вторым аргументом cv2.COLOR_BGR2GRAY, который отвечает за перевод в градации серого. Далее производим пороговую обработку при помощи cv2.threshold. Все пиксели изображения ниже определенного порога устанавливаются в 0, все, что выше, в 255. За значение порога отвечает второй аргумент функции cv2.threshold. В нашем случае там может стоят любое число, так как мы используем cv2.THRESH_OTSU и функция сама определит оптимальный порог по методу Оцу на основе гистограммы изображения.

image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)_, thresh = cv2.threshold(image, 0, 255, cv2.THRESH_OTSU)

image

Цветовая сегментация


Дальше интереснее. Усложним задачу и найдем все символы красного цвета на экране выбора уровней.

image

По умолчанию, все изображения OpenCV хранит в формате BGR. Для цветовой сегментации больше подходит HSV (Hue, Saturation, Value тон, насыщенность, значение). Ее преимущество перед RGB заключается в том, что HSV отделяет цвет от его насыщенности и яркости. Цветовой тон кодируется одним каналом Hue. Возьмем для примера салатовый прямоугольник и будем постепенно уменьшать его яркость.

image

В отличии от RGB, в HSV данное преобразование выглядит интуитивно мы просто уменьшаем значение канала Value или Brightness. Тут стоит обратить внимание на то, что в эталонной модели шкала оттенков Hue варьируется в диапазоне 0-360. Наш салатовый цвет соответствует 90. Для того, чтобы уместить это значение в 8 битный канал, его следует разделить на 2.
Сегментация цветов работает с диапазонами, а не с одним цветом. Определить диапазон можно опытным путем, но проще написать небольшой скрипт.

import cv2import numpy as numpyimage_path = "tests_data/SELECT_LEVEL.png"hsv_max_upper = 0, 0, 0hsv_min_lower = 255, 255, 255def bite_range(value):    value = 255 if value > 255 else value    return 0 if value < 0 else valuedef pick_color(event, x, y, flags, param):    if event == cv2.EVENT_LBUTTONDOWN:        global hsv_max_upper        global hsv_min_lower        global image_hsv        hsv_pixel = image_hsv[y, x]        hsv_max_upper = bite_range(max(hsv_max_upper[0], hsv_pixel[0]) + 1), \                        bite_range(max(hsv_max_upper[1], hsv_pixel[1]) + 1), \                        bite_range(max(hsv_max_upper[2], hsv_pixel[2]) + 1)        hsv_min_lower = bite_range(min(hsv_min_lower[0], hsv_pixel[0]) - 1), \                        bite_range(min(hsv_min_lower[1], hsv_pixel[1]) - 1), \                        bite_range(min(hsv_min_lower[2], hsv_pixel[2]) - 1)        print('HSV range: ', (hsv_min_lower, hsv_max_upper))        hsv_mask = cv2.inRange(image_hsv, numpy.array(hsv_min_lower), numpy.array(hsv_max_upper))        cv2.imshow("HSV Mask", hsv_mask)image = cv2.imread(image_path)cv2.namedWindow('Original')cv2.setMouseCallback('Original', pick_color)image_hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)cv2.imshow("Original", image)cv2.waitKey(0)cv2.destroyAllWindows()

Запустим его с нашим скриншотом.

image

Кликаем по красному цвету и смотрим на полученную маску. Если вывод нас не устраивает выбираем оттенкам красного, увеличивая диапазон и площадь маски. Работа скрипта основана на функции cv2.inRange, которая работает как цветовой фильтр и возвращает пороговое изображение для заданного цветового диапазона.
Остановимся на следующих диапазонах:

    COLOR_HSV_RANGE = {   COLOR_BLUE: ((112, 151, 216), (128, 167, 255)),   COLOR_ORANGE: ((8, 251, 93), (14, 255, 255)),   COLOR_RED: ((167, 252, 223), (171, 255, 255)),   COLOR_GREEN: ((71, 251, 98), (77, 255, 211)),   COLOR_YELLOW: ((27, 252, 51), (33, 255, 211)),   COLOR_WHITE: ((0, 0, 159), (7, 7, 255)),}

Поиск контуров


Вернемся к нашему экрану выбора уровней. Применим цветовой фильтр красного диапазона, который мы только что определили, и передадим найденный порог в cv2.findContours. Функция найдет нам контуры красных элементов. Укажем вторым аргументом cv2.RETR_EXTERNAL нам нужны только внешние контуры, и третьим cv2.CHAIN_APPROX_SIMPLE нас интересуют прямые контуры, экономим память и храним только их вершины.

thresh = cv2.inRange(image, self.COLOR_HSV_RANGE[self.COLOR_RED][0], self.COLOR_HSV_RANGE[self.COLOR_RED][1])contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE

image

Удаление шума


Полученные контуры содержат много шума от фона. Чтобы убрать его воспользуемся свойством наших цифр. Они состоят из прямоугольников, которые параллельны осям координат. Перебираем все контуры и вписываем каждый в минимальный прямоугольник при помощи cv2.minAreaRect. Прямоугольник определяется 4 точками. Если наш прямоугольник параллелен осям, то одна из координат для каждой пары точек должны совпадать. Значит у нас будет максимум 4 уникальных значения, если представить координаты прямоугольника как одномерный массив. Дополнительно отфильтруем слишком длинные прямоугольники, где соотношение сторон больше, чем 3 к 1. Для этого найдем их ширину и длину при помощи cv2.boundingRect.

squares = []        for cnt in contours:            rect = cv2.minAreaRect(cnt)            square = cv2.boxPoints(rect)            square = numpy.int0(square)            (_, _, w, h) = cv2.boundingRect(square)            a = max(w, h)            b = min(w, h)            if numpy.unique(square).shape[0] <= 4 and a <= b * 3:                squares.append(numpy.array([[square[0]], [square[1]], [square[2]], [square[3]]]))

image

Объединение контуров


Уже лучше. Теперь нам нужно объединить найденные прямоугольники в общий контур символов. Нам понадобится промежуточное изображение. Создадим его при помощи numpy.zeros_like. Функция создает копию матрицы image с сохранением ее формы и размера, затем заполняет ее нулями. Другими словами, мы получили копию нашего оригинального изображения, залитую черным фоном. Переводим его в 1-канальное и наносим найденные контуры при помощи cv2.drawContours, заполнив их белым цветом. Получаем бинарный порог, к которому можно применить cv2.dilate. Функция расширяет белую область, соединяя отдельные прямоугольники, расстояние между которыми в пределах 5 пикселей. Еще раз вызываем cv2.findContours и получаем контуры красных цифр.

        image_zero = numpy.zeros_like(image)        image_zero = cv2.cvtColor(image_zero, cv2.COLOR_BGR2RGB)        cv2.drawContours(image_zero, contours_of_squares, -1, (255, 255, 255), -1)  _, thresh = cv2.threshold(image_zero, 0, 255, cv2.THRESH_OTSU)  kernel = numpy.ones((5, 5), numpy.uint8)        thresh = cv2.dilate(thresh, kernel, iterations=1)        dilate_contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

image

Оставшийся шум отфильтруем по площади контуров при помощи cv2.contourArea. Убираем все, что занимает меньше 500 пикселей.

digit_contours = [cnt for cnt in digit_contours if cv2.contourArea(cnt) > 500]

image

Вот теперь отлично. Реализуем все вышеописанное в нашем классе Robot.

# ...class Robot:         # ...        def get_dilate_contours(self, image, color_inx, distance):        thresh = self.get_color_thresh(image, color_inx)        if thresh is False:            return []        kernel = numpy.ones((distance, distance), numpy.uint8)        thresh = cv2.dilate(thresh, kernel, iterations=1)        contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)        return contours    def get_color_thresh(self, image, color_inx):        if color_inx == self.COLOR_ALL:            image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)            _, thresh = cv2.threshold(image, 0, 255, cv2.THRESH_OTSU)        else:            image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)            thresh = cv2.inRange(image, self.COLOR_HSV_RANGE[color_inx][0], self.COLOR_HSV_RANGE[color_inx][1])        return threshdef filter_contours_of_rectangles(self, contours):        squares = []        for cnt in contours:            rect = cv2.minAreaRect(cnt)            square = cv2.boxPoints(rect)            square = numpy.int0(square)            (_, _, w, h) = cv2.boundingRect(square)            a = max(w, h)            b = min(w, h)            if numpy.unique(square).shape[0] <= 4 and a <= b * 3:                squares.append(numpy.array([[square[0]], [square[1]], [square[2]], [square[3]]]))        return squares    def get_contours_of_squares(self, image, color_inx, square_inx):        thresh = self.get_color_thresh(image, color_inx)        if thresh is False:            return False        contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)        contours_of_squares = self.filter_contours_of_rectangles(contours)        if len(contours_of_squares) < 1:            return False        image_zero = numpy.zeros_like(image)        image_zero = cv2.cvtColor(image_zero, cv2.COLOR_BGR2RGB)        cv2.drawContours(image_zero, contours_of_squares, -1, (255, 255, 255), -1)        dilate_contours = self.get_dilate_contours(image_zero, self.COLOR_ALL, 5)        dilate_contours = [cnt for cnt in dilate_contours if cv2.contourArea(cnt) > 500]        if len(dilate_contours) < 1:            return False        else:            return dilate_contours

Распознание цифр


Добавим возможность распознания цифр. Зачем нам это нужно? Потому что мы можем . Данная возможность не является обязательной для работы бота и при желании ее можно смело вырезать. Но так как мы обучаемся, добавим ее для подсчета набранных очков и для понимания бота, на каком он шаге на уровне. Зная завершающий ход уровня, бот будет искать кнопку перехода на следующий или повтор текущего. Иначе пришлось бы осуществлять их поиск после каждого хода. Откажемся от использования Tesseract и реализуем все средствами OpenCV. Распознание цифр будет построено на сравнении hu моментов, что позволит нам сканировать символы в разном масштабе. Это важно, так как в интерфейсе игры есть разные размеры шрифта. Текущий, где мы выбираем уровень, определим SQUARE_BIG_SYMBOL: 9, где 9 средняя сторона квадрата в пикселях, из которых состоит цифра. Кадрируем изображения цифр и сохраним их в папке data. В словаре self.dilate_contours_bi_data у нас содержатся эталоны контуров, с которым будет происходить сравнение. Индексом будет название файла без расширения (например digit_0).

# class Robot:    # ...    SQUARE_BIG_SYMBOL = 0x01    SQUARE_SIZES = {        SQUARE_BIG_SYMBOL: 9,      }    IMAGE_DATA_PATH = "data/"     def __init__(self):        # ...        self.dilate_contours_bi_data = {}         for image_file in os.listdir(self.IMAGE_DATA_PATH):            image = cv2.imread(self.IMAGE_DATA_PATH + image_file)            contour_inx = os.path.splitext(image_file)[0]            color_inx = self.COLOR_RED            dilate_contours = self.get_dilate_contours_by_square_inx(image, color_inx, self.SQUARE_BIG_SYMBOL)            self.dilate_contours_bi_data[contour_inx] = dilate_contours[0]    def get_dilate_contours_by_square_inx(self, image, color_inx, square_inx):        distance = math.ceil(self.SQUARE_SIZES[square_inx] / 2)        return self.get_dilate_contours(image, color_inx, distance)

В OpenCV для сравнения контуров на основе Hu моментов используется функция cv2.matchShapes. Она скрывает от нас детали реализации, принимая на вход два контура и возвращает результат сравнения в виде числа. Чем оно меньше, тем более схожими являются контуры.

cv2.matchShapes(dilate_contour, self.dilate_contours_bi_data['digit_' + str(digit)], cv2.CONTOURS_MATCH_I1, 0)

Сравниваем текущий контур digit_contour со всеми эталонами и находим минимальное значение cv2.matchShapes. Если минимальное значение меньше 0.15, цифра считается распознанной. Порог минимального значения найден опытным путем. Также объединим близко расположенные символы в одно число.

# class Robot:    #     def scan_digits(self, image, color_inx, square_inx):        result = []        contours_of_squares = self.get_contours_of_squares(image, color_inx, square_inx)        before_digit_x, before_digit_y = (-100, -100)        if contours_of_squares is False:            return result        for contour_of_square in reversed(contours_of_squares):            crop_image = self.crop_image_by_contour(image, contour_of_square)            dilate_contours = self.get_dilate_contours_by_square_inx(crop_image, self.COLOR_ALL, square_inx)            if (len(dilate_contours) < 1):                continue            dilate_contour = dilate_contours[0]            match_shapes = {}            for digit in range(0, 10):                match_shapes[digit] = cv2.matchShapes(dilate_contour, self.dilate_contours_bi_data['digit_' + str(digit)], cv2.CONTOURS_MATCH_I1, 0)            min_match_shape = min(match_shapes.items(), key=lambda x: x[1])            if len(min_match_shape) > 0 and (min_match_shape[1] < self.MAX_MATCH_SHAPES_DIGITS):                digit = min_match_shape[0]                rect = cv2.minAreaRect(contour_of_square)                box = cv2.boxPoints(rect)                box = numpy.int0(box)                (digit_x, digit_y, digit_w, digit_h) = cv2.boundingRect(box)                if abs(digit_y - before_digit_y) < digit_y * 0.3 and abs(                        digit_x - before_digit_x) < digit_w + digit_w * 0.5:                    result[len(result) - 1][0] = int(str(result[len(result) - 1][0]) + str(digit))                else:                    result.append([digit, self.get_contour_centroid(contour_of_square)])                before_digit_x, before_digit_y = digit_x + (digit_w / 2), digit_y        return result


На выходе метод self.scan_digits выдаст массив, содержащий распознанную цифру и координату клика по ней. Точкой клика будет центроид ее контура.

# class Robot:    # def get_contour_centroid(self, contour):        moments = cv2.moments(contour)        return int(moments["m10"] / moments["m00"]), int(moments["m01"] / moments["m00"])

Радуемся полученной распознавалке цифр, но не долго. Hu моменты помимо масштаба инвариантны также к повороту и зеркальности. Следовательно бот будет путать цифры 6 и 9 / 2 и 5. Добавим дополнительную проверку этих символов по вершинам. 6 и 9 будем отличать по правой верхней точке. Если она ниже горизонтального центра, значит это 6 и 9 для обратного. Для пары 2 и 5 проверяем, лежит ли верхняя правая точка на правой границе символа.

if digit == 6 or digit == 9:    extreme_bottom_point = digit_contour[digit_contour[:, :, 1].argmax()].flatten()    x_points = digit_contour[:, :, 0].flatten()    extreme_right_points_args = numpy.argwhere(x_points == numpy.amax(x_points))    extreme_right_points = digit_contour[extreme_right_points_args]    extreme_top_right_point = extreme_right_points[extreme_right_points[:, :, :, 1].argmin()].flatten()    if extreme_top_right_point[1] > round(extreme_bottom_point[1] / 2):        digit = 6    else:        digit = 9if digit == 2 or digit == 5:    extreme_right_point = digit_contour[digit_contour[:, :, 0].argmax()].flatten()    y_points = digit_contour[:, :, 1].flatten()    extreme_top_points_args = numpy.argwhere(y_points == numpy.amin(y_points))    extreme_top_points = digit_contour[extreme_top_points_args]    extreme_top_right_point = extreme_top_points[extreme_top_points[:, :, :, 0].argmax()].flatten()    if abs(extreme_right_point[0] - extreme_top_right_point[0]) > 0.05 * extreme_right_point[0]:        digit = 2    else:        digit = 5

image

image

Анализируем игровое поле


Пропустим тренировочный уровень, он заскриптован по клику на белом курсоре и приступаем к игре.

Представим игровое поле как сеть. Каждая область цвета будет узлом, который связан с граничащими рядом соседями. Создадим класс self.ColorArea, который будет описывать область цвета/узел.

class ColorArea:         def __init__(self, color_inx, click_point, contour):            self.color_inx = color_inx  # индекс цвета            self.click_point = click_point  # клик поинт области            self.contour = contour  # контур области            self.neighbors = []  # индексы соседей

Определим список узлов self.color_areas и список того, как часто встречается цвет на игровом поле self.color_areas_color_count. Кадрируем игровое поле из скриншота канвы.

image[pt1[1]:pt2[1], pt1[0]:pt2[0]]

Где pt1, pt2 крайние точки кадра. Перебираем все цвета игры и применяем к каждому метод self.get_dilate_contours. Нахождение контура узла аналогично тому, как мы искали общий контур символов, с тем отличием, что на игровом поле отсутствуют шумы. Форма узлов может быть вогнутой или иметь отверстие, поэтому центроид будет выпадать за пределы фигуры и не подходит в качестве координата для клика. Для этого найдем экстремальную верхнюю точку и опустимся на 20 пикселей. Способ не универсальный, но в нашем случае рабочий.

        self.color_areas = []        self.color_areas_color_count = [0] * self.SELECT_COLOR_COUNT        image = self.crop_image_by_rectangle(self.screenshot, numpy.array(self.GAME_MAIN_AREA))        for color_inx in range(1, self.SELECT_COLOR_COUNT + 1):            dilate_contours = self.get_dilate_contours(image, color_inx, 10)            for dilate_contour in dilate_contours:                click_point = tuple(                    dilate_contour[dilate_contour[:, :, 1].argmin()].flatten() + [0, int(self.CLICK_AREA)])                self.color_areas_color_count[color_inx - 1] += 1                color_area = self.ColorArea(color_inx, click_point, dilate_contour)                self.color_areas.append(color_area)

image

Связываем области


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

        blank_image = numpy.zeros_like(image)        blank_image = cv2.cvtColor(blank_image, cv2.COLOR_BGR2GRAY)        for color_area_inx_1 in range(0, len(self.color_areas)):            for color_area_inx_2 in range(color_area_inx_1 + 1, len(self.color_areas)):                color_area_1 = self.color_areas[color_area_inx_1]                color_area_2 = self.color_areas[color_area_inx_2]                if color_area_1.color_inx == color_area_2.color_inx:                    continue                common_image = cv2.drawContours(blank_image.copy(), [color_area_1.contour, color_area_2.contour], -1, (255, 255, 255), cv2.FILLED)                kernel = numpy.ones((15, 15), numpy.uint8)                common_image = cv2.dilate(common_image, kernel, iterations=1)                common_contour, _ = cv2.findContours(common_image, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)                if len(common_contour) == 1:self.color_areas[color_area_inx_1].neighbors.append(color_area_inx_2)self.color_areas[color_area_inx_2].neighbors.append(color_area_inx_1)

image

Ищем оптимальный ход


У нас есть вся информация об игровом поле. Приступим к выбору хода. Для этого нам нужен индекс узла и цвета. Количество вариантов хода можно определить формулой:

Варианты ходов = Количество узлов * Количество цветов 1

Для предыдущего игрового поля у нас есть 7*(5-1) = 28 вариантов. Их немного, поэтому мы можем перебрать все ходы и выбрать оптимальный. Определим варианты как матрицу
select_color_weights, в которой строкой будет индекс узла, столбцом индекс цвета и ячейкой вес хода. Нам нужно уменьшить количество узлов до одного, поэтому отдадим приоритет областям, цвет которых уникален на игровом поле и которые исчезнут после хода на них. Дадим +10 к весу ко все строке узла с уникальным цветом. Как часто встречается цвет на игровом поле, мы ранее собрали в self.color_areas_color_count

if self.color_areas_color_count[color_area.color_inx - 1] == 1:   select_color_weight = [x + 10 for x in select_color_weight]

Далее рассмотрим цвета соседних областей. Если у узла есть соседи цвета color_inx, и их количество равно общему количеству данного цвета на игровом поле, назначим +10 к весу ячейки. Это также уберет цвет color_inx с поля.

for color_inx in range(0, len(select_color_weight)):   color_count = select_color_weight[color_inx]   if color_count != 0 and self.color_areas_color_count[color_inx] == color_count:      select_color_weight[color_inx] += 10

Дадим +1 к весу ячейки за каждого соседа одного цвета. То есть если у нас есть 3 красных соседа, красная ячейка получит +3 к весу.

for select_color_weight_inx in color_area.neighbors:   neighbor_color_area = self.color_areas[select_color_weight_inx]   select_color_weight[neighbor_color_area.color_inx - 1] += 1

После сбора всех весов, найдем ход с максимальным весом. Определим к какому узлу и к какому цвету он относится.

max_index = select_color_weights.argmax()self.color_area_inx_next = max_index // self.SELECT_COLOR_COUNTselect_color_next = (max_index % self.SELECT_COLOR_COUNT) + 1self.set_select_color_next(select_color_next)

Полный код для определения оптимального хода.

# class Robot:    # def scan_color_areas(self):        self.color_areas = []        self.color_areas_color_count = [0] * self.SELECT_COLOR_COUNT        image = self.crop_image_by_rectangle(self.screenshot, numpy.array(self.GAME_MAIN_AREA))        for color_inx in range(1, self.SELECT_COLOR_COUNT + 1):            dilate_contours = self.get_dilate_contours(image, color_inx, 10)            for dilate_contour in dilate_contours:                click_point = tuple(                    dilate_contour[dilate_contour[:, :, 1].argmin()].flatten() + [0, int(self.CLICK_AREA)])                self.color_areas_color_count[color_inx - 1] += 1                color_area = self.ColorArea(color_inx, click_point, dilate_contour, [0] * self.SELECT_COLOR_COUNT)                self.color_areas.append(color_area)        blank_image = numpy.zeros_like(image)        blank_image = cv2.cvtColor(blank_image, cv2.COLOR_BGR2GRAY)        for color_area_inx_1 in range(0, len(self.color_areas)):            for color_area_inx_2 in range(color_area_inx_1 + 1, len(self.color_areas)):                color_area_1 = self.color_areas[color_area_inx_1]                color_area_2 = self.color_areas[color_area_inx_2]                if color_area_1.color_inx == color_area_2.color_inx:                    continue                common_image = cv2.drawContours(blank_image.copy(), [color_area_1.contour, color_area_2.contour],                                                -1, (255, 255, 255), cv2.FILLED)                kernel = numpy.ones((15, 15), numpy.uint8)                common_image = cv2.dilate(common_image, kernel, iterations=1)                common_contour, _ = cv2.findContours(common_image, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)                if len(common_contour) == 1:                    self.color_areas[color_area_inx_1].neighbors.append(color_area_inx_2)                    self.color_areas[color_area_inx_2].neighbors.append(color_area_inx_1)    def analysis_color_areas(self):        select_color_weights = []        for color_area_inx in range(0, len(self.color_areas)):            color_area = self.color_areas[color_area_inx]            select_color_weight = numpy.array([0] * self.SELECT_COLOR_COUNT)            for select_color_weight_inx in color_area.neighbors:                neighbor_color_area = self.color_areas[select_color_weight_inx]                select_color_weight[neighbor_color_area.color_inx - 1] += 1            for color_inx in range(0, len(select_color_weight)):                color_count = select_color_weight[color_inx]                if color_count != 0 and self.color_areas_color_count[color_inx] == color_count:                    select_color_weight[color_inx] += 10            if self.color_areas_color_count[color_area.color_inx - 1] == 1:                select_color_weight = [x + 10 for x in select_color_weight]            color_area.set_select_color_weights(select_color_weight)            select_color_weights.append(select_color_weight)        select_color_weights = numpy.array(select_color_weights)        max_index = select_color_weights.argmax()        self.color_area_inx_next = max_index // self.SELECT_COLOR_COUNT        select_color_next = (max_index % self.SELECT_COLOR_COUNT) + 1        self.set_select_color_next(select_color_next)

Добавим возможность перехода между уровнями и радуемся результату. Бот работает стабильно и проходит игру за одну сессию.


Вывод


Созданный бот не несет никакой практической пользы. Но автор статьи искренне надеется, что подробное описание базовых принципов OpenCV поможет новичкам разобраться с данной библиотекой на начальном этапе.
Подробнее..

Взламываем Ball Sort Puzzle

08.01.2021 12:15:36 | Автор: admin
Определение кружочков при помощи OpenCVОпределение кружочков при помощи OpenCV

Ball Sort Puzzle это популярная мобильная игра на IOS/Android. Суть её заключается в перестановке шариков до тех пор, пока в колбах не будут шарики одного цвета. При этом шарик можно перетаскивать либо в пустую колбу, либо на такой же шарик.

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

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

Ну это ни в какие ворота против нас играет коварный ИИ. Нужно действовать соответственно!

Под катом мы:

  • Придумаем алгоритм, решающий эту головоломку (Python)

  • Научимся парсить скриншот игры, чтобы скармливать алгоритму задачки (OpenCV)

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

  • Выстроим CI/CD через GitHub Actions и задеплоим бота на Яндекс.Функции

Погнали!


Алгоритмическое решение задачи

Решать такую задачу было весьма занимательно. Поэтому предлагаю заинтересованному читателю попробовать решить её самостоятельно.

Я же в первую очередь решил побить проблему на сущности. Это сделает алгоритм чуть более элегантным, а так же поможет в будущем парсить скриншоты игры:

class Color
class Color:    def __init__(self, symbol, verbose_name, emoji):        self.symbol = symbol        self.verbose_name = verbose_name        self.emoji = emoji    def __repr__(self) -> str:        return f'Color({self})'    def __str__(self) -> str:        return self.emoji
Beta-редактор хабра ломается на рендеринге emoji :poop:Beta-редактор хабра ломается на рендеринге emoji :poop:
class Ball
class Ball:    def __init__(self, color: Color):        self.color = color    def __eq__(self, other: 'Ball'):        return self.color is other.color    def __repr__(self):        return f'Ball({self.color.verbose_name})'    def __str__(self) -> str:        return str(self.color)
class Flask
class Flask:    def __init__(self, column: List[Color], num: int, max_size: int):        self.num = num        self.balls = [Ball(color) for color in column]        self.max_size = max_size    @property    def is_full(self):        return len(self.balls) == self.max_size    @property    def is_empty(self) -> bool:        return not self.balls    def pop(self) -> Ball:        return self.balls.pop(-1)    def push(self, ball: Ball):        self.balls.append(ball)    def __iter__(self):        return iter(self.balls)    def __getitem__(self, item: int) -> Ball:        return self.balls[item]    def __len__(self) -> int:        return len(self.balls)    def __str__(self) -> str:        return str(self.balls)
class Move
class Move:    def __init__(self, i, j, i_color: Color):        self.i = i        self.j = j        self.emoji = i_color.emoji    def __eq__(self, other: 'Move') -> bool:        return (self.i, self.j) == (other.i, other.j)    def __repr__(self) -> str:        return f'Ball({self})'    def __str__(self) -> str:        return f'{self.i} -> {self.j}'

Для решения будем использовать метод поиска с возвратом (Backtracking).

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

В случае с нашей игрой это метод применяется так: мы рекурсивно обходим все возможные перестановки шариков (move) до тех пор, пока

  • Либо нас не выкинет наш критерий остановки решённый пазл

  • Либо в нашем хранилище состояний (states) не будет всех возможных перестановок в таком случае решения нет

    def solve(self) -> bool:        if self.is_solved:            return True        for move in self.get_possible_moves():            new_state = self.commit_move(move)            if new_state in self.states:  # Cycle!                self.rollback_move(move)                continue            self.states.add(new_state)            if self.solve():                return True            self.rollback_move(move)        return False

Алгоритм достаточно прямолинейный и далеко не всегда выдаёт оптимальное решение. Тем не менее он справляется с решением большинства задачек из игры за 1 сек.

Проверим алгоритм на чём-нибудь попроще:

def test_3x3():    data_in = [        [color.RED, color.GREEN, color.RED],        [color.GREEN, color.RED, color.GREEN],        [],    ]    puzzle = BallSortPuzzle(data_in)    result = puzzle.solve()    assert result is True    play_moves(data_in, puzzle.moves)
Алгоритм в действииАлгоритм в действии

Полная версия программы доступна на github.

Распознавание скриншотов игры

Мы будем работать с .jpg картинками двух видов

Скриншоты уровней игры Скриншоты уровней игры

Каждый чётный раунд игры состоит из 11 колб и 36 шариков, а нечётный 14 колб и 48 шариков. Чётные и нечётные раунды отличаются расположением колб, но на счастье всё остальное у них одинаковое по 4 шарика в колбе, 2 колбы пустые, цвета используются одни и те же.

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

class ImageParser:    def __init__(self, file_bytes: np.ndarray, debug=False):        self.image_orig = cv2.imdecode(file_bytes, cv2.IMREAD_COLOR)        self.image_cropped = self.get_cropped_image(self.image_orig)    @staticmethod    def get_cropped_image(image):        height, width, _ = image.shape        quarter = int(height / 4)        cropped_img = image[quarter : height - quarter]        return cropped_img
Рабочая областьРабочая область

Теперь будем искать кружочки. В библиотеке OpenCV ровно для этих целей существует метод HoughCircles. Чтобы его использовать нужно перевести изображение в чёрно-белый вид, а также "эмпирически подобрать" параметры поиска. Чтобы найденные кружочки потом расфасовать по колбам, нормализуем и отсортируем их.

    @staticmethod    def normalize_circles(circles):        last_y = 0        for circle in circles:            if math.isclose(circle[1], last_y, abs_tol=3):                circle[1] = last_y            else:                last_y = circle[1]        return circles    def get_normalized_circles(self) -> List[Any]:        image_cropped_gray = cv2.cvtColor(self.image_cropped, cv2.COLOR_BGR2GRAY)        circles = cv2.HoughCircles(image_cropped_gray, cv2.HOUGH_GRADIENT, 2, 20, maxRadius=27)        if circles is None:            raise ImageParserError("No circles :shrug:")        circles = np.round(circles[0, :]).astype("int16")        ind = np.lexsort((circles[:, 0], circles[:, 1]))        circles = circles[ind]        circles = self.normalize_circles(circles)        ind = np.lexsort((circles[:, 0], circles[:, 1]))        circles = circles[ind]        return circles
Отсортированные шарики слева-направо, сверху-внизОтсортированные шарики слева-направо, сверху-вниз

Дальше будем определять цвет шарика.

Из-за того, что Telegram жмёт картинки мы не можем просто взять цвет центрального пикселя он может быть артефактом компрессии. Поэтому найдём доминирующий цвет, тот который в кружочке встречается чаще всего.

    @staticmethod    def get_dominant_color(circle) -> Color:        colors, count = np.unique(circle.reshape(-1, circle.shape[-1]), axis=0, return_counts=True)        dominant = colors[count.argmax()]        return dominant
Найденные кружочки Найденные кружочки

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

d = \sqrt{(r2-r1)^2 + (b2-b1)^2 + (g2-g1)^2}

Посчитаем такое расстояние до каждого из изначально заданных цветов и найдём минимальное

RBG_TO_COLOR = {    (147, 42, 115): VIOLET,    (8, 74, 125): BROWN,    (229, 163, 85): L_BLUE,    (68, 140, 234): ORANGE,    (196, 46, 59): BLUE,    (51, 100, 18): GREEN,    (35, 43, 197): RED,    (87, 216, 241): YELLOW,    (125, 214, 97): L_GREEN,    (123, 94, 234): PINK,    (16, 150, 120): LIME,    (102, 100, 99): GRAY,}COLORS = np.array(list(RBG_TO_COLOR.keys()))def get_closest_color(color: np.ndarray) -> Color:    distances = np.sqrt(np.sum((COLORS - color) ** 2, axis=1))    index_of_smallest = np.where(distances == np.amin(distances))    smallest_distance = COLORS[index_of_smallest].flat    return RBG_TO_COLOR[tuple(smallest_distance)]  # type: ignore

Далее нам остаётся только распределить шарики по колбам. Итоговый class ImageParser доступен на github.

Преобразуем программу в Telegram Bot

Узнать про то, как сделать телеграм бота на Python можно сразу из нескольких статей на хабре. Я лишь опишу пару нюансов, с которыми столкнулся.

Так как наш бот хоститься на Яндекс.Функции триггером к его запуску будет запрос на заданный нами webhook.

Whenever there is an update for the bot, we will send an HTTPS POST request to the specified url, containing a JSON-serializedUpdate.

Если в сообщении есть массив photo, то можно увеличить вероятность распознавания шариков выбрав фотографию с максимальным весом:

if photos := message.get('photo'):    # here photos is an array with same photo of different sizes    # get one with the highest resolution    hd_photo = max(photos, key=lambda x: x['file_size'])

Чтобы скачать картинку, придётся сделать 2 запроса к Telegram API

# Получение данных о файле, нас интересует ключ ответа file_pathGET https://api.telegram.org/bot{BOT_TOKEN}/getFile?file_id={file_id}# Получение самого файлаGET https://api.telegram.org/file/bot{BOT_TOKEN}/{file_path}

В остальном же всё просто получаем картинку, скармливаем её парсеру и затем алгоритму-решателю.

main.py
def handler(event: Optional[dict], context: Optional[dict]):    body = json.loads(event['body'])  # type: ignore    print(body)    message = body['message']    chat_id = message['chat']['id']    if photos := message.get('photo'):        # here photos is an array with same photo of different sizes        hd_photo = max(photos, key=lambda x: x['file_size'])  # get one with the highest resolution        try:            file = telegram_client.download_file(hd_photo['file_id'])        except TelegramClientError:            text = "Cant download the image from TG :("        else:            file_bytes = np.asarray(bytearray(file.read()), dtype=np.uint8)            try:                image_parser = ImageParser(file_bytes)                colors = image_parser.to_colors()            except ImageParserError as exp:                text = f"Cant parse image: {exp}"            else:                puzzle = BallSortPuzzle(colors)  # type: ignore                solved = puzzle.solve()                if solved:                    text = get_telegram_repr(puzzle)                else:                    text = "This lvl don't have a solution"    else:        return {            'statusCode': 200,            'headers': {'Content-Type': 'application/json'},            'body': '',            'isBase64Encoded': False,        }    msg = {        'method': 'sendMessage',        'chat_id': chat_id,        'text': text,        'parse_mode': 'Markdown',        'reply_to_message_id': message['message_id'],    }    return {        'statusCode': 200,        'headers': {'Content-Type': 'application/json'},        'body': json.dumps(msg, ensure_ascii=False),        'isBase64Encoded': False,    }

Отмечу ещё один нюанс: телеграм очень строго следует политике экранирования спецсимволов. Для Markdown это:

To escape characters '_', '*', '`', '[' outside of an entity, prepend the characters '\' before them.

Любой такой неэкранированный символ и вы не увидите ответа в телеграм-чате. И останется только гадать является ли это ошибка интеграции или вот такой коварный баг. Будьте осторожны.

Деплой бота в Яндекс.Функцию

Про создание Я.Функции также есть отличная статья от @mzaharov. Там подробно описан процесс заведения функции, а также установки вебхука для телеграмм бота.

Я расскажу как сделал Continuous Delivery при помощи GitHub Actions. Каждая сборка мастера увенчивается деплоем новой версии функции. Такой подход заставляет придерживаться модели разработки GithubFlow с его главным манифестом

Anything in themasterbranch is always deployable.

Каждая сборка мастера состоит из 3ёх этапов

  • lint (black, flake8, isort, mypy) проверка кода на соответствие всем стандартам Python 2020

  • test тестируем программу с помощью pytest, поддерживая качество и покрытие кода

  • deploy непосредственно заливаем новую версию приложения в облако

Деплоить будем с помощью Yandex-Serveless-Action уже готового Action для использования в своих пайплайнах

  deploy:    name: deploy    needs: pytest    runs-on: ubuntu-latest    if: github.ref == 'refs/heads/master'    steps:      - uses: actions/checkout@master      - uses: goodsmileduck/yandex-serverless-action@v1        with:          token: ${{ secrets.YC_TOKEN }}          function_id: ${{ secrets.YC_FUNCTION_ID }}          runtime: 'python38'          memory: '256'          execution_timeout: "120"          entrypoint: 'main.handler'          environment: "\            TELEGRAM_BOT_TOKEN=${{ secrets.TELEGRAM_BOT_TOKEN }}"          source: 'app'

Переменные окружения программы и сборки спрячем в GitHub Secrets на уровне репозитория.

Результат

Пример работы @ballsortpuzzlebotПример работы @ballsortpuzzlebot

Бота можно найти в telegram по позывному @ballsortpuzzlebot.

Все исходники на Github.

Присоединяйтесь к маленькому community любителей этой игры в telegram. Бот был добавлен в группу и внимательно следит за всеми отправленными картинками.

Бонус! Уровни, у которых нет решения
Lvl 4091Lvl 4091Lvl 6071Lvl 6071

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

Заключение

Для меня это был интересный опыт скрещивания технологий (Telegram API + Python + OpenCV + Lambda). Надеюсь он окажется полезен кому-нибудь ещё.

Найденные баги, предложения по оптимизации алгоритма, или даже PR в репозиторий крайне приветствуются

С новым годом!

Подробнее..

Поиск нарушений на видео с помощью компьютерного зрения

05.03.2021 14:13:31 | Автор: admin

Предположим, что данное нарушение возможно. Как же его выявить?

В нашем распоряжении имеются записи с камер наблюдения рабочего места сотрудника и журнал проведения операций.

Будем искать все моменты на записи, где отсутствовал клиент. В этом нам поможет нейронная сеть MobileNet и CSRT Tracker из библиотеки opencv. А для удобства еще и Tesseract-OCR.

Чтобы найти человека в кадре будем использовать нейросеть MobileNet. Данная сеть позволяет обнаружить и локализовать 20 типов объектов на изображении. Для ее работы необходимо скачать два файла: архитектуру и веса. Данные файлы можно найти в репозитории Github.

Перед написанием кода нам понадобится установка библиотеки компьютерного зрения cv2 и пакета для обработки текста на изображениях pytesseract.

!pip install opencv-python!pip install pytesseract

Для работы pytesseract необходимо предварительно скачать дистрибутив Tesseract-OCR c официального сайта и установить его.

Начинаем подготовку к обработке видео

Импортируем пакеты и прописываем в локальное окружение путь к папке с Tesseract-OCR:

import osvideo_path = ... #Путь к видеоtesseract_path = ... #Путь к установленному Tesseractos.environ["PATH"] += os.pathsep + tesseract_pathimport pytesseractimport cv2import imutilsimport pandas as pdimport datetime as dt

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

df = pd.DataFrame(columns = ['Время', 'Клиент в кадре'])work_place = () #Область, где сидит клиентdate = None #Область на видео с временем и датойtracked = False #Состояние отслеживания

Укажем пути к файлам с архитектурой и весами нейронной сети, которые мы скачали ранее. Если они лежат в папке с проектом, то просто запишем их названия:

prototxt = 'MobileNetSSD_deploy.prototxt' #Модельweights = 'MobileNetSSD_deploy.caffemodel' #Веса

Как сказано выше данная нейросеть может различать 20 классов объектов, запишем их в словарь:

classNames = {0: 'background',              1: 'aeroplane',              2: 'bicycle',              3: 'bird',              4: 'boat',              5: 'bottle',              6: 'bus',              7: 'car',              8: 'cat',              9: 'chair',              10: 'cow',              11: 'diningtable',              12: 'dog',              13: 'horse',              14: 'motorbike',              15: 'person',              16: 'pottedplant',              17: 'sheep',              18: 'sofa',              19: 'train',              20: 'tvmonitor'}

Выставим уровень доверия низким, так как шанс ошибиться и классифицировать другой объект на рабочем месте как человека крайне низкий.

thr = 0.1 #Уровень доверия

Инициализируем нейронную сеть:

net = cv2.dnn.readNetFromCaffe(prototxt, weights) #Нейросеть

Создадим объект cv2.VideoCapture, с помощью которого мы будем воспроизводить видео:

cap = cv2.VideoCapture(video_path)

Заявленная частота кадров в секунду в видео может отличаться от действительной, поскольку метод .read(), которым мы вскоре воспользуемся может пропускать некоторые кадры. Так как нас интересует точное время с камеры наблюдения, мы должны знать сколько кадров помещается в одной секунде из видео. Для этого необходимо узнать общее количество кадров в записи и разделить его на его длительность. Выполним следующий код:

%%timecap = cv2.VideoCapture(video_path)total_frame = 0while True:    success, frame = cap.read()    if success:        total_frame += 1    else:        break        video_length = ... #Длительность видео в секундахfps = round(total_frame / video_length)fps

Так как количество кадров в секунду может быть не целым числом, то возможна погрешность. В 100-минутном ролике погрешность составляла 2 секунды.

Обработка видео

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

while cap.isOpened():    ret, frame = cap.read()        if ret:                frame = imutils.resize(frame, width=1200) #Качество кадра, влияет на быстродействие        #Получение участка кадра, где расположено рабочее место        if len(work_place) == 0:            cv2.putText(frame, 'Set the client\'s location', (0, 90), cv2.FONT_HERSHEY_SIMPLEX,                 2, (0,255,0), 2)            work_place = cv2.selectROI('frame', frame, fromCenter=False, showCrosshair=True)            x, y, w, h = [int(coord) for coord in work_place]                    #Получение даты        if not date:            try:                cv2.putText(frame, 'Set the date, (0, 160), cv2.FONT_HERSHEY_SIMPLEX,                     2, (0,255,0), 2)                date = cv2.selectROI('frame', frame, fromCenter=False, showCrosshair=True)                date_x, date_y, date_w, date_h = [int(coord) for coord in date]                date_ = frame[date_y : date_y+date_h, date_x : date_x+date_w]                date_ = cv2.cvtColor(date_, cv2.COLOR_BGR2GRAY) #Приводим к градации серого                #date_ = cv2.threshold(date_, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1]                date_ = cv2.threshold(date_, 180, 255, 0)[1] #Можно менять значения для лучшего распознавания                date = pytesseract.image_to_string(date_)                date = dt.datetime.strptime(date, '%Y-%m-%d %H:%M:%S')                            except:                print('Распознать дату не удалось, введите дату вручную в формате ГОД-МЕСЯЦ-ДЕНЬ ЧАС:МИНУТ:СЕКУНД')                date_ = input()                date = dt.datetime.strptime(date_, '%Y-%m-%d %H:%M:%S')                        if cap.get(1) % fps == 0:            date += dt.timedelta(seconds = 1)                if not tracked or (cap.get(1) % (fps * 30) == 0):            #Предобработка кадра            frame_resized = cv2.resize(frame, (300, 300)) #Подгон изображения под 300 на 300 пикс            blob = cv2.dnn.blobFromImage(frame_resized, 0.007843,                                          (300,300), (127.5, 127.5, 127.5), False)            #Прямой проход кадра по нейросети            net.setInput(blob)            detections = net.forward()             #[0, 0, object, [0, class_id, confidence, xLeftBottom, yLeftBottom, xRightTop, yRightTop]]            #Запоминаем размеры предобработанного кадра            cols = frame_resized.shape[1]            rows = frame_resized.shape[0]            #Детекция класса и получение его рамки исходных размеров            for obj in detections[0,0, :, :]:                confidence = obj[2]                if confidence > thr:                    class_id = int(obj[1])                    if class_id == 15:                        xLeftBottom = int(obj[3] * cols)                        yLeftBottom = int(obj[4] * rows)                        xRightTop   = int(obj[5] * cols)                        yRightTop   = int(obj[6] * rows)                        #Отношения размеров оригинального и сжатого кадра                        heightFactor = frame.shape[0] / 300.0                        widthFactor = frame.shape[1] / 300.0                        #Границы объекта на несжатом кадре                        xLeftBottom = int(widthFactor * xLeftBottom)                        yLeftBottom = int(heightFactor * yLeftBottom)                        xRightTop   = int(widthFactor * xRightTop)                        yRightTop   = int(heightFactor * yRightTop)                        #Нахождения центра рамки границы объекта                        xCenter = xLeftBottom + (xRightTop - xLeftBottom)/2                        yCenter = yLeftBottom + (yRightTop - yLeftBottom)/2                        #Проверка вхождения объекта в выделенную область                        if xCenter < x + w and yCenter < y + h and xCenter > x and yCenter > y:                            tracker = cv2.TrackerCSRT_create()                            tracker.init(frame, (xLeftBottom, yLeftBottom, xRightTop-xLeftBottom, yRightTop-yLeftBottom))                            tracked = True                            cv2.rectangle(frame, (xLeftBottom,yLeftBottom), (xRightTop,yRightTop), (0,255,0), 3, 1)                            break                        else:                            tracked = False        else:            _, bbox = tracker.update(frame)            X, Y, W, H = [int(coord) for coord in bbox]            xCenter = X + W/2            yCenter = Y + H/2                        if xCenter < x + w and yCenter < y + h and xCenter > x and yCenter > y:                                tracked = True                cv2.rectangle(frame, (X,Y), (X + W, Y + H), (255,255,0), 3, 1)            else:                tracked = False        cv2.imshow('frame', frame)        df.loc[cap.get(1), :] = [date, tracked]        print(cap.get(1), date, tracked) #Вывод номера кадра, даты и наличия/отсутствия клиента        if cv2.waitKey(1) == 27: #ESC            break    else:        breakcap.release()cv2.destroyAllWindows()

В представленном выше коде метод .read() возвращает нам два значения: логическую переменную, обозначающую успешное чтение кадра, и второе значение сам кадр. Далее мы проверяем, указано ли место клиента, если нет, то нам предлагается обвести прямоугольную область.

Затем мы таким же образом обводим дату в кадре. Как правило, дата представлена в формате год-месяц-день часы:минуты:секунды. Выделенная область приводится к оттенкам серого, а затем пиксели разбиваются на два цвета: черный и белый, в зависимости от насыщенности оттенка серого. Результат записывается в переменную date.

В таком виде Tesseract-у проще сделать оптическое распознавание символов
Если оптическое распознавание не удалось, то пользователю предлагается ввести дату вручную. Как правило, распознавание не удается, если на фоне даты находятся участки такого же цвета.

Метод .get() с цифрой 1 возвращает номер текущего кадра, и, если он кратен вычисленному нами ранее fps из видео, то прибавляем к нашему времени в переменной date одну секунду. Мы могли в каждом кадре распознавать дату с помощью tesseract, но тогда наша обработка затянулась бы на дни, так как оптическое распознавание очень ресурсоемкий процесс.

Далее мы предобрабатываем кадр для прогона его через нейросеть: масштабируем с помощью метода cv2.resize() и изменяем цвет пикселей методом cv2.dnn.blobFromImage(). После чего, мы подаем предобработанный кадр в нейросеть. Обратно мы получаем предсказание, которое мы запишем в переменную detections. В ней будут содержаться сведения о найденных объектах, вероятности их отнесения к одному из 20 классов и координаты местоположения в кадре.

Поскольку нас интересует только человек, мы ищем все объекты с индексом 15. Если вероятность нахождения человека в кадре выше нашего доверительного уровня, и человек находится в пределах выделенного места, то мы присваиваем переменной tracked значение True и передаем найденные координаты трекеру. Результаты tracked и переменной date записываем в таблицу df.

После этого трекер начинает отслеживать клиента до тех пор, пока тот не выйдет из выделенной области. Как только клиент покидает область, переменная tracked меняется с True на False, и включается нейросеть для поиска следующего клиента.

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

В конце мы получим таблицу, где для каждого кадра записано время и наличие / отсутствие клиента в выделенной области. Сгруппируем ее по секундам и будем считать, что если хотя бы в одном кадре за секунду есть клиент, то он там действительно есть. Если сопоставить полученное время в кадре с журналом проведения операций, то можно выявить наличие или отсутствие нарушений.

df_ = df.groupby('Время', as_index=False).agg(max)df_.to_excel('output.xlsx', index=False)

Хочется отметить, что у данного подхода имеется ряд недостатков:

  • Медленная работа трекера. Мы используем трекер, основанный на корреляционных фильтрах. Это один из реализованных трекеров в библиотеке opencv. Он обладает хорошей точностью, относительно других трекеров из библиотеки, но является самым медленным.

  • Трекер может потерять из виду объект. При резких движениях или окклюзии трекер может потерять или начать отслеживать совершенно другой объект.

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

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

Ссылка на код.

Подробнее..

Цифровой рентген прогулка по Эльбрусу

15.10.2020 14:05:55 | Автор: admin

Привет Хабр!


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


Интро


На старте проекта нам удалось найти дополнительное финансирование, основным условием была работа ПО полная кроссплатформенность, в том числе поддержка отечественных процессоров. На тот момент наиболее производительным вариантом для десктоп машин был Эльбрус 8С (пока он им и остается, 8СВ еще вроде не вышел). Мы купили две станции Эльбрус 801-РС напрямую от МЦСТ. Сейчас их стоимость указана на сайте, год назад были чуть дороже.


Из курьезных мелочей, с которыми столкнулись при закупке бумажные заявки на поставку. Сразу советую заключить договор NDA, это даст доступ к свежим инструментам разработчика (оформление около месяца). Приехали машины быстро, по комплектации есть проблема с кастомизацией на стороне МЦСТ. Проще докупить и поставить самим нужные видеокарты или периферию. Перечень проверенного оборудования/чипов карт пока есть только в багтрекере МЦСТ, хотя стоило бы опубликовать список на вики ресурсе по Эльбрусам.


Операционные системы


По ситуации на начало 2020го года при заказе можно выбрать предустановленную ОС: оригинальную Эльбрус или партнерскую (Альт, Астра). В нашем проекте используется Астра Linux. Она хоть и была доступна официально, но по факту не получилось заказать за адекватное время. Ставили сами с лайв образа отдельной лицензией.


Выбор той или иной ОС дело вкуса, мне понравилось окружение Fly в Астре, субъективно оно шустрее. С другой стороны, пакетная база Альта шире, они теснее сотрудничают с МЦСТ и набор дистрибутивов интереснее.
Пример не существовал USB лайв образ Астры под Эльбрус, а для Альта их было аж несколько штук. Бэкап раздела Астры работал только из-под лайв образа Альта.


Сама ОС Эльбрус изначально разрабатывалась для демо процессора заказчикам. Мы начинали с версии 4.0, сейчас рабочая 5.0rc2 стало лучше, но все же она сыровата для конечного пользователя. Нужна для отладки или получения максимума от VLIW архитектуры. Именно на этой ОС производительность при обработке изображений была максимальной.


UPD: сейчас вышла версия ОС Эльбрус 6.0. Там заявлены C++20 и свежее ядро Linux, но так как мы работаем на Астра обновления еще не добрались.


Архитектура


Пока только С++, 14-ый стандарт, про CUDA и Vulkan лучше не думать, а вот OpenGL на AMD видеокартах работает нормально. Важно OpenGL не старше 3.1, QT 5.11.
По остальным ЯП возможно кто-то поделится свои опытом в комментариях. Знаю, что идет работа в закрытых конторах, в основном по обработке видеопотока и изображений. Понемногу народ осваивает отечественные процессоры.


Теперь слово архитектору проекта, Максиму Титову (titovmaxim, Unicore Solutions)
Наша цель сделать хороший софт для работы с рентгенографическими системами. Поэтому начали с главного подключения железа и тракта обработки изображений. Сильно волновались. TLDR: все прошло гладко.


Главная часть системы детектор рентгеновского излучения. Подключается по Ethernet и забивает канал 1Гбит под завязку, протокол GigE Vision. Коммерческие библиотеки под Эльбрус отсутствуют, библиотеки с открытым исходным кодом (например Aravis) не подходят по скорости и качеству, так что писали свою реализацию.


Столкнулись с тем, что встроенная сетевая карта материнской платы Эльбруса сильно нагружает процессор, и 1ГБит просто так не получить, теряются кадры. Пришлось дописывать дополнительный быстрый промежуточный буфер, чтобы разгрузить поток приема. Обрабатываем все потом в отдельных потоках. Все заработало, канал забивается полностью, потерь кадров нет. При установке на Эльбрус дискретной карты проблем становится заметно меньше.


Предварительная обработка изображений в реальном времени реализована на OpenGL, т.к. это единственное средство, доступное нам на всех платформах. В нее входит применение калибровки и коррекция битых пикселей детектора, обработка гистограммы, гамма коррекция, повышение резкости и подавление шума, раскраска полутонов, геометрические преобразования. Интересно было написать быструю оценку гистограммы изображения на OpenGL, а не CUDA, задача не совсем типичная.
Итого на обработку одного кадра 3000x3000 16 бит на самой слабой видеокарте (AMD R5) на Эльбрусе уходит ~33 мс. Очень даже неплохо, при условии независимости от ОС и полной разгрузки ЦП для задач сложной пост-обработки. Уже доступны более мощные видеокарты, так что здесь мы спокойны. К примеру, на x86 с GeForce RTX 2070 Max-Q мы получаем стабильные ~2мс, ждем подобного на Эльбрус
Подключение прочих компонентов рентгеновских генераторов и мехатроники по RS232, Web камер по UVC, прошло без проблем.


В проекте мы используем Qt 5.11 и QML для интерфейса. Здесь все прошло неожиданно гладко. Работает, заводится "из-коробки", основные пакеты в Астра есть. Ждем, когда будут доступны обновления, поскольку в 5.11 есть шероховатости и баги.
Немного о том, почему мы опасались C++ 14. Активное совместное использование C++ и QML часто вызывает сложности, поэтому мы решили использовать в проекте cвою библиотеку Flow.


Библиотека Flow


Среди плюсов библиотеки декларативность, сокращение boilerplate кода и ошибок. Помогает использовать ФП подход на С++, в частности есть гибкое объединение и композиция функций, ленивость, кеширование, потокобезопасность и фоновое выполнения в других потоках. Последнее, кстати, актуально для OpenGL, который не очень умеет в многопоточность. Все это в динамике с оповещением об изменениях (никаких update) отстыковки/пристыковки веток, действия (эффекты в смысле ФП), интегрированы с контекстами Qt. Из приятного автоматический контроль времени жизни объектов и подписок без ручных subscribe/unsubscribe и беспокойств, кто кого переживёт, все само :) Немного похоже на ReactiveX, но тут больше про состояния, а не про потоки данных.


Там же живет своя мета-система (в C++ рефлексию не завезли), используем вместо QMetaObject. Писать меньше, лучше интегрируется с QML (примерно, как WPF с C#), можно свободно перемещаться по дереву, в частности работать из QML с QVector в середине дерева как с моделью с умным diffом (без написания QAbstractItemModel), автоматическая сериализация/десериализация любого объекта одной командой и пр.
Библиотека изначально была рассчитана минимум на C++ 17. При переходе на C++ 14 мы, естественным образом, потеряли вывод типов и сейчас приходится прописывать большинство шаблонных параметров руками. Однако прогресс МЦСТ впечатляет, ждем новых компиляторов. Была пара особенностей компилятора Эльбрус, не замеченных ранее на GCC и MSVC. Не всегда понимаются auto параметры в лямбдах. Не умеет перезахватывать this во вложенные лямбды. Но это легко правится, в остальном все компилируется и работает без проблем. Да, ошибки компилятора на русском языке немного непривычны
Вследствие особенностей архитектуры на Эльбус не рекомендуется использовать исключения. Поэтому мы сразу отказались от них в нагруженных частях. Однако достаточно широко используем их на верхнем уровне, где операции единичные. Все работает как положено, зря боялись. Да, пока не завезли -fnon-call-exceptions для удобного отлова ошибок уровня системы и их обработки на месте возникновения.
Приятно, что можно большую часть кода писать "дома" на Linux под x86 и потом просто собирать на удаленной машине Эльбруса. При некоторой сноровке проблем с совместимостью кода почти не возникает.


Производительность OpenCV


Наиболее критичные вещи по обработке видеопотока вынесены на видеокарту, для неторопливой пост-обработки изображений используем OpenCV 3.2. Этот пакет портирован на Эльбрус, но есть нюанс производительность сильно зависит от версии пакета для конкретной ОС. См. таблицу по сравнению производительности пакетов OpenCV на Эльбрус 8С (1300 МГц) и Intel core i7 (2600 МГц) под разными ОС/сборками openCV:


Таблица сравнения openCV Эльбрус vs Intel i7
Операция Параметры ОС Эльбрус 5.0rc2 Эльбрус-8С OpenCV 3.2 ОС Астра Ленинград 8.1 Эльбрус-8С OpenCV 3.2 ОС Астра Смоленск 1.6 Intel Core i7 OpenCV 3.2 ОС Windows 10 Intel Core i7 OpenCV 3.2 ОС Windows 10 Intel Core i7 OpenCV 4.4
Свертка ядро 5x5, 3000x3000, 16S 35 334 99,7 94 105,9
Свертка ядро 5x5, 3000x3000, 16U 244 280 - 98 106,5
Свертка ядро 5x5, 3000x3000, 32F 32 271 23,9 24 11,4
Гауссово размытие ядро 5x5, 3000x3000, 16S 15,3 257 36,3 35 5,7
Гауссово размытие ядро 5x5, 3000x3000, 16U 184 251 - 12,5 40
Гауссово размытие ядро 5x5, 3000x3000, 32F 14,5 222 8,1 7,7 6,2

Производительность OpenCV на Эльбрусах напрямую завязана на низкоуровневые EML библиотеки (см. руководство по программированию МЦСТ, они оптимизированы под VLIW архитектуру). А пакеты EML зависит уже от дистрибутива ОС. В используемой нами Астре свежая сборка до сих пор не появилась, возможно она есть в Альт Линукс. Кто игрался напишите в комментариях.
Если говорить про рутину свертку изображений, то производительность может быть в 2 раза лучше (16S) по сравнению с i7, а может быть и в 2 раза хуже (32F). На сборке ОС с неоптимизированной библиотекой OpenCV проигрыш в производительности до 20 раз. И да, с 16U у Эльбруса пока все плохо.


Резюме


Жить на Эльбрусе можно. Постепенно МЦСТ и партнеры (Альт, Астра) допиливают ПО, улучшают сервис, снижают цены. Это радует.
С другой стороны их ресурсы ограничены, чего-то нужного именно вам можно ждать полгода.
Возможно правительство станет более активно продвигать отечественные процессоры и стимулировать их коммерческую эксплуатацию, посмотрим. Мы в любом случае к этому готовы.

Подробнее..

Как запихать нейронку в кофеварку

27.10.2020 10:11:01 | Автор: admin
Мир машинного обучения продолжает стремительно развиваться. Всего за год технология может стать мейнстримом, и разительно измениться, придя в повседневность.
За прошедший год-полтора, одной из таких технологий, стали фреймворки выполнения моделей машинного обучения. Не то, что их не было. Но, за этот год, те которые были стали сильно проще, удобнее, мощнее.

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

Перед началом статьи нужно сразу сказать несколько дисклеймеров:
  • Мой обзор будет со стороны ComputerVision. Не стоит забывать что ML это не только CV. Это ещё классические алгоритмы бустинга, это различный NLP (от трансформеров до синтеза речи). И далеко не все задачи ML можно будет исполнять на тех фреймворках про которые я буду говорить.
  • Из обозначенных технологий я сам работал где-то с третью. Про остальные читал/минимально щупал/общался с людьми которые интегрировали. Отсюда могут возникнуть какие-то ошибки в статье. Если вы видите что-то что вас покоробило/с чем вы не согласны пишите в комментариях/в личку попробую поправить.
  • Мир стремительно меняется. Я пробую верифицировать то что пишу на момент выхода статьи. Но не факт что даже существующая документация соответствует истинному положению дел. Не говоря уже о том, что в ближайшие пару недель любой из указанных фреймворков может катастрофически измениться. Если вдруг такие апдейты вам будут интересны то скорее всего я буду разбирать их в своём блоге CVML (он же в телеге), где я такую мелочь пишу. Тут буду стараться оставлять ссылки на разбор.

Для тех кому лень читать записал видео с содержимым статьи:

Начнём.

Часть 1. Что такое инференс


Мир нейронных сетей можно разбить на две части:
  • обучение
  • использование

Обучение сложнее. Оно требует больше математики, возможность анализировать какие-то параметры по ходу. Нормой является подключение к TensorBoard и прочим инструментам мониторинга. Нужны простые способы интеграции новых слоёв, быстрота модификации.
Чаще всего для обучения используются Nvidia GPU (да, есть TPU от Google, или Intel Xe, но скорее это редкость). Так что софт для обучения должен хорошо поддерживать одну платформу.
Нужно ли обучение при использовании нейронной сети в проде? Очень редко. Да, у нас было несколько проектов с автоматическим дообучением. Но лучше этого избегать. Это сложно и нестабильно.
Да и если нужно, то проще утащить на внешний сервак и там дообучить.
image
Как следствие можно отбросить 90% математики и обвеса, использовать только выполнение. Это и называется инференс. Он быстрее, чем обучение. Требует сильно меньше математики.
Но вот засада. Не тащить же для инференса GPU. Инференс может быть и на десктопах, и на мобильниках, и на серверах, и в браузере.
В чём его сложность?
Нейронные сети едят много производительности нужно либо специальное железо и его поддержка, либо максимальная утилизация существующего, чтобы хоть как-то достать производительность.
А железо очень-очень разное. Например в телефонах Android может существовать с десяток различных вычислителей, каждый из которых имеет свою архитектуру. А значит ваш модуль должен быть универсален для большинства.

Часть 2. Железо.


Железо в ML бывает очень разное. Его хотя бы примерное описание на текущий момент будет требовать десятка статей. Могу вам посоветовать очень крутую статью от 3Dvideo про технологии аппаратного ускорения нейронных сетей. И свою статью про то как устроены embedding системы в последнее время.
И то и то уже немного неактуально, ведь всё быстро-быстро меняется;)

Для упрощения понимания статьи, или для тех кто не хочет углубляться я накидал упрощённую схему которой мы будем оперировать (кликабельно):

Тут мы видим несколько направлений инференса:
  • Серверный инференс. Мы не полезем в него глубоко. Обычно когда вам нужно чтобы сеть выполнялась на сверхпроизводительном сервере редко нужно чтобы данный алгоритм крутился на старой мобилке. Но, тем не менее, тут будет что помянуть
  • Десктопный инференс. Это то, что может крутиться у вас на домашнем компе. Обработка фотографий. Анализ видео, компы которые ставятся на предприятия, и прочее и прочее будет именно здесь.
  • Мобильный инференс всё что касается телефонов, Android и Ios. Тут огромное поле. Есть GPU, есть специальные ускорители, есть сопроцессоры, и прочее и прочее.
  • Embedded. Частично эта часть пересекается с десктопами, а частично с мобильными решениями. Но я вынес её в отдельную ветку, так как часть ускорителей ни на что не похожи.

Основная проблема видна уже тут. Большинство конечных точек требуют свою систему инференса. Если вы хотите универсальную систему выполнения нейронной сети которая будет использовать 100% мощности, тогда вам нужно иметь модуль для каждой конечной точки этого дерева.
Есть фреймворки которые теряют в разветвленности, но неплохо работают там где работают. Есть которые используют максимально популярные устройства, но не работают на других. Опять же, если показать это ооочень примерно, то выйдет как-то так:

Поговорим подробнее, скорее слева на право.

Специализированное железо: Gyrfalcon, Khadas, Hikvision, и.т.д.


Всё это добро я решил выделить в одну группу. Фреймворком это назвать сложно. Обычно это специализированный софт который может перенести нейронку на железо, чтобы она выполнялась там оптимально. При этом железяка у производителей зачастую одна или две.
Отличительной особенностью таких платформ является:
  • Не все слои поддерживаются
  • Софт часто не выложен в OpenSource. Поставляется вместе с аппаратной частью или требует подписания дополнительного NDA

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

Специализированное железо но от крупных фирм


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

Nvidia TensorRT


Наверное это самая классика, и не надо рассказывать почему. Больше 90% ресёрча использует именно видюхи NVIDIA. Инференс часто на них же => все стараются выжать максимум, а для этого нужны TensorRT специальный фреймворк который максимально утилизирует мощь видеокарты для нейронных сетей.
Более того, если вы пишите на CUDA то можете в рамках одного обработчика обрабатывать данные. Самый классический пример NMS. Например, когда-то давно мы переписывали кусок одного детектора поз на CUDA чтобы не гонять данные на процессор. И это очень ускоряло его работу.
Нужно понимать, что NVIDIA целит в три области, и везде TensorRT используется:
  • Серверные платформы (Tesla)
  • Десктопы (видеокарты обычные и полу специализированные)
  • Embedded платформы серии Jetson.

Плюс TensorRT в том, что он достаточно стандартен. Он есть в TensorFlow (tf-trt), есть в OpenCV. Основной минус для меня под Windows нет поддержки в Python, только через сторонние проекты. А я люблю иногда что-то под виндой потыкать.
Мне кажется, что если вы делаете инференс на Nvidia, то у вас просто нет альтернатив надо использовать TensorRT. Всё остальное приведёт к падению производительности.

Triton Inference Server


Но, так как мы говорим про инференс, то нужно упомянуть Triton Inference Server ( github.com/triton-inference-server/server ). Это не совсем TensorRT (хотя TensorRT подразумевается как оптимальный для него фреймворк). Triton может использовать TensorFlow, PyTorch, Caffe. Он сам ограничивает память и настраивает любой из упомянутых фреймворков, управляя выполняющимися сетями.
Triton сам решает какие модели загружать-выгружать из памяти, сам решает какой batch использовать. Может использовать не только TensorRT модели, но и модели *.pd, *.pth, ONNX, и.т.д., (ведь далеко не все можно сконвертировать в tensorrt). Triton может раскладывать по нескольким GPU. И прочее и прочее.
Мы использовали его в продакшне в нескольких проектах и остались ужасно довольны. Максимальная утилизация GPU с минимумом проблем.
Но Он не сможет выполнить модель где-то за пределами Nvidia

Intel OpenVino


Intel представлен на рынке:
  • Серверных вычислителей (Intel FPGA, Xe GPU, Xeon)
  • Десктопов (с i3 начиная года с 2015 поддерживается почти всё). Intel GPU работает но не сверх круто.
  • Embedded платформ (movidius)

И для всего можно сварить модель через OpenVino.
Мне нравиться OpenVino так как он достаточно стабилен, прост, и имеет инференс почти везде. Я писал на Хабре статью в которой рассказывал опыт одного хоббийного проекта под Intel. Там я отлаживал на десктопах, а тестировал на RPi с мовидиусом. И все было норм.
В целом, все 2-3 проекта которые мы делали в своей практике под OpenVino, прошли примерно так же. Минимум сложностей, удобный инференс, заказчик доволен.
Open Vino интегрирован в OpenCV про который мы ещё поговорим. OpenCV тоже от Intel, но я бы рассматривал его как отдельный фреймворк/способ инференса и подхода к данным.
Отдельно я бы хотел отметить один забавный момент. Аренда GPU сервера для того чтобы развернуть модель в онлайне будет стоить где-то от 10к. рублей, где-то до 100к. рублей, в зависимости от используемых видюх.
Аренда сервака с I5 на каком-нибудь клауде зачастую возможна за 500-1000 рублей в месяц.
Разница в производительности между TensorRT и ONNX на сравнимых по цене процах может быть в 2-20 раз (зависит от сети). Как следствие часто можно неплохо сэкономить перенеся онлайн инференс на ONNX.

Google Edge


Google очень неоднозначная фирма сама по себе. Мало того, что Google имеет свой стек аппаратуры для инференса нейронных сетей. Ещё у Google целый стек различных фреймворков про которые мы поговорим позже. Запутанный и местами бажный. Но в целом это:
  • TensorFlow Edge
  • TensorFlow lite
  • TensorFlow JS
  • TensorFlow (чистый, или оптимизированный под какую-то платформу, например Intel TensorFlow)

В этой части мы говорим именно про отдельную ветку Edge ( coral.ai ). Она используется в Embedded продуктах, и в некоторых mobile продуктах.
Минусом Edge является то, что из коробки поддерживаются не все модели, гарантирована поддержка лишь нескольких ( coral.ai/models ). А конвертация достаточно усложнена (TF -> TF Lite -> Edge TPU):
image
Конвертация в Cloud TPU чуть проще, но и там есть свои особенности.
Я не буду более подробно рассказывать про особенности этого фреймворка. Скажу лишь что каждый раз когда с ним сталкивался оставались неприятные впечатления, так что не стали тащить его нигде в прод.

Универсализация с хорошей аппаратной поддержкой


Мы переходим в область подходов, которые достаточно оптимально используют железо, но будут требовать немного разный код на разных платформах (нельзя же на Android всё сделать на Python).
Тут я бы упорядочил как-то так (опять же, очень условно):

Пройдем по этому списку

OpenCV


OpenCV это легендарный фреймворк ComputerVision появившийся более 15 лет назад. За свои 12 лет занятий ComputerVision я запускал его на Arm году в 2010, на BlackFin примерно тогда же, на мобильниках, на серверах, на RPi, на Jetson, и много-много где. У него есть классные биндинги на Python, Java, JavaScript, когда-то я вовсю использовал его на C#.
OpenCV более чем живой и сейчас. И нейронные сети активно просачиваются туда с самого их появления.
Мне кажется, что на сегодня у OpenCV есть несколько глобальных минусов:
  • Очень мало актуальной документации. Попробуйте найти где-нибудь гайд по всем бекендам модуля DNN в OpenCV и по поддержке их на всех платформах?:) Конечно, можно изучить хедеры, но это не говорит о том что где будет работать. Порог входа и первого прототипа достаточно высок. Не в пример того же OpenVino.
  • Нейронные сети под Android поддерживаются ( docs.opencv.org/master/d0/d6c/tutorial_dnn_android.html ). Но я не находил актуального гайда про аппаратную поддержку GPU или других вычислителей.

Тут кажется, что всё грустно. Но! Стоит поставить НО даже большими буквами. Главный плюс OpenCV очень хорошая поддержка CUDA и OpenVINO. Судя по всему запланирована и поддержка любых OpenCL устройств, но пока информации мало.
По нашим тестам в OpenCV нейронные сети на CUDA выполняются медленнее чем в TensorRT всего на 5-10%, что отлично.
Это делает OpenCV весьма ценным фреймворком для серверных решений, где нужно выжимать максимум из имеющегося ускорителя, какой бы он не был.

Так же OpenCV очень неплохо поддерживает различные CPU на ARM-устройствах. На том же RPi он использует NEON.

Tensorflow lite


Чуть ближе к мобильному миру лежит TensorFlow lite. Он может исполнять нейронные сети как и на обычном GPU мобильного устройства, так и на возможных сопроцессорах, если производитель телефона соблюдает какой-то набор стандартов. Для мобильников возможные варианты выглядят примерно так:
image
В большинстве случаев вы автоматически можете проверить максимальный уровень ускорения и запустить именно на нём. Детальной карты того какие вендоры предоставляют какую поддержку железа я не нашёл. Но я видел несколько примеров которые начались поддерживаться. Например под Snapdragon мы когда-то портировали сети, а потом TFLite начал его поддерживать.
Чуть более подробно о том что насколько даёт прирост можно посмотреть, например, тут ai-benchmark.com/ranking_detailed
Так же, стоит упомянуть, что изначально поддержка GPU шла за счёт OpenGL, но в последнее время Google добавила и OpenCL, чем почти в 2 раза ускорила выполнение сетей.
Но прелесть TFlite не только в том что он работает под мобильниками. Он ещё достаточно неплох для части embedded устройств. Например, он достаточно эффективно работает под RaspberryPI.
На последнем DataFest ребята из X5 рассказывали что они используют эту конструкцию в продакшне.
Так же, TFlite, за счёт XNNPACK неплохо работает на процессорах (но не так эффективно как OpenVino на Intel). Это даёт возможность использовать TFLite как способ инферить модели на десктопах. Хоть и без поддержки GPU. Зато без тонны лишних зависимостей.

ONNX runtime


В какой-то момент в гонку ворвался Microsoft, который решил зайти со стороны. Сначала они выпустили универсальный формат сохранения нейронных сетей, который сейчас достаточно популярен, ONNX.
И, когда уже форматом многие начали пользоваться, решили сделать свой рантайм для него.

Если честно, то я даже не знаю половину того что представлено в списке..:)
Выглядит всё идеально (а ещё всё поддерживается на Python, Java, C, C++, C# и.т.д.)
Но пока что мне не довелось использовать ONNX-runtime на практике, кроме ONNX.js, о котором будет ниже. Так что не могу гарантировать что всё работает идеально. В интернетах слишком мало примеров того как всё работает. И знакомых которые бы на этом разворачивали продакшн полноценный тоже не знаю (максимум тестировали но не решили развернуть).
Но в гайдах уверяется что даже для Raspberry PI и Jetson есть поддержка.
Про поддержку ios явно ничего не сказано. Но местами ios встречается по коду. А кто-то билдит.

PyTorch


Одна из проблем использования OpenCV, TFlite, ONNX Runtime: А почему мне надо обучать в одном фреймворке, а использовать в другом?. И авторы PyTorch тоже задают себе такой вопрос. Так что, прямо из коробки, предоставляют способ использования PyTorch на мобильниках:
image
Скажу честно, я не тестировал скорости инференса. Но по опросу знакомых в целом все считают это медленным вариантом. По крайней мере инференс PyTorch на процессорах и GPU тоже не самый быстрый. Хотя, опять же, XNNPACK используют.
Но, данный вариант вполне удобен для разработки и пуша в продакшн (нет лишних конвертаций, и.т.д.). Мне кажется, что иногда это хороший вариант (например когда нет требований на очень высокую производительность).

TensorFlow


Кроме использования TFlite можно использовать и оригинальный TF, для развертывания на embedded устройствах и десктопах. При этом TF имеет достаточно высокую степень конфигурируемости. Но, на мой взгляд, это достаточно мертвый вариант в будущем.

Китайское вторжение


Теперь мы переходим к двум вариантам, которые не тестировал почти никто из моих знакомых. Но которые выглядят весьма перспективно.
Первый из них MNN, вариант от alibaba.
image
Авторы уверяют, что MNN самый легковесный из фреймворков, который обеспечивает инференс на большом числе устройств, при использовании GPU или CPU. При этом поддерживает большой спектр моделей.

Второй вариант интереснее, это ncnn от Tencent. Согласно документации библиотека работает на большом числе устройств. Реализована на c++:

На неё даже Yolov4 спортировано. И в целом, AlexeyAB очень позитивно отзывался.
Но, опять же, у меня достаточно мало информации о практическом применении. Сам не использовал, в проектах которые видел/консультировал никто не использовал.
При этом можно отметить, что не так много нативных решений одновременно поддерживают и телефоны и nvidia.

JS Tensorflow.JS, ONNX.js


Я бы объединил эти две категории в общий раздел, хотя, конечно, у TensorFlow.js и ONNX.js есть много различий. TensorFlow чуть лучше поддерживает оптимизацию. ONNX чуть быстрее. LSTM не присутствует в ONNX. И прочее и прочее.
image
В чем особенность этого класса инференс фреймворков? В том, что они выполняются только на CPU, или на GPU. По сути через WebGL или через WebAssembly. Это наборы инструкций, которые доступны через браузерный JS.
Как плюс имеем офигенную универсальность такого подхода. Написав один раз код на JS можно исполнять его на любом устройстве. Если есть GPU на нём. Если только CPU на нём.
Основной минус тоже понятен. Никакой поддержки за пределами CPU или GPU (ускорители/сопроцессоры). Невозможно использовать эффективные способы утилизации CPU и GPU (те же OpenVino или TensorRT).
Правда, под Node.js, TF.js умеет в TPU:
image

При этом этот вариант явно оптимальный, если вам нужно использовать простые модели, имея кроссплатформенность.

Мы использовали в проектах оба инференса, всё работало хорошо. Но, конечно, производительность немного жалко.

Как небольшое резюме


Выводы сложно делать. Я не смог придумать какого-то однозначного алгоритма который бы помогал выбрать платформу на которой нужно разворачивать ML решение в зависимости от бизнес требований, аппаратуры и сложности сетей.
Как мне кажется:
  • Если вам нужно максимально широкое использование на всех платформах, то, похоже, наиболее универсален ONNX runtime. Единственное, он может местами сыроват + ios поддержан весьма просто. Не уверен, что все ускорители одинаково хорошо работают. Вариант Tencent-ncnn
  • Если вам нужно максимум платформ, без мобильных, то я бы взял OpenCV. Он достаточно удобен и стандартен.
  • Если вам не нужны десктопные платформы, тогда я бы использовал TFlite
  • Если же вы хотите делать на какой-то стандартной платформе, и чётко понимаете что никуда за её пределы не пойдёте то я бы использовал специализированную платформу под эту платформу. Будь это TensorRT или OpenVino. И на TensorRT и на OpenVino мы разворачивали очень сложные проекты. Непреодолимых проблем не встретили.

В целом (очень условно) как-то так:


А так Тема объёмная. Ведь чтобы достаточно подробно рассмотреть TensorFlow lite со всеми плюсами и минусами надо написать две таких статьи. А для полного обзора OpenCV и десяти не хватит. Но сил написать столько нет.
С другой стороны, я надеюсь, написанное поможет кому-то хоть немного структурировать логику при выборе платформы для инференса.
А если у вас есть и другие идеи как выбирать пишите!

P.S.


В последнее время делаю много мелких статей/видеороликов. Так как это не формат Хабра то публикую их в блоге или на ютубе. Подборка всего есть в телеге и вк.
На Хабре обычно публикую, когда рассказ становится уже более самозамкнутым, иногда собрав 2-3 разных мини-рассказа на соседние темы.
Подробнее..

Архитектура облачного волейбольного сервиса

11.11.2020 08:04:10 | Автор: admin
Не так давно я писал про волейбольный сервис, теперь пришло время описать его с технической точки зрения.

Возможно, общественное сознание найдет изъяны в архитектуре и подтолкнет к лучшим решениям.

Краткое описание функциональности:

  • пользователь загружает видео с записью волейбольной игры
  • один хитрый алгоритм распознает мяч на кадрах
  • другой хитрый алгоритм выделяет розыгрыши
  • розыгрыши компонуются в отдельные видеофайлы
  • файлы с розыгрышами собираются в дайджест всей игры
  • все видео заливается в облако
  • пользователи смотрят/качают/шарят клипы с самыми классными розыгрышами


Например, такими:



Теперь, как это все работает.

Технологии


Все написано на python, веб-сервис Django/Gunicorn.
Интенсивно используются OpenCV и FFMpeg.
База данных Postgres.
Кэш и очередь Redis.

Альфа


В самой первой версии было 3 компонента:
  • Front Веб сервис (Django), с которым взаимодействуют конечные пользователи
  • Videoproc (Vproc) Ядро алгоритма, python + opencv, которое содержит все алгоритмы треккинга мяча и логику нарезки на розыгрыши
  • Clipper Сервис генерации видео на основе выхлопа Vproc, используя ffmpeg




Разработка велась локально, я поставил на домашний десктоп Ubuntu, а на нее microk8s и получил маленький Кубернетес-кластер.

Здесь я столкнулся с тем, что кадры, которые видит opencv не очень совпадают с временем, которое видит ffmpeg из-за особенностей кодеков, поэтому высчитать время в оригинальном файле из номера кадра оказалаось невозможно.
Из-за этого пришлось сохранять кадры, что сильно замедлило процесс и потребовало дальнейших архитектурных изменений.

Параллельная обработка


Как уже упоминалось, время обработки в 3 раза превышает время игры. Профайлер показал, что, большая часть времени тратится при записи кадров на диск, сам же разбор кадров примерно в два раза быстрее.
Логично распараллелить эти две работы.
По начальной задумке пока vproc разбирает кадры через opencv, ffmpeg параллельно записывает все на диск, а clipper собирает из них видео.
Но с ffmpeg нашлись две проблемы:
  • Кадры из ffmpeg не идентичны кадрам из opencv (это не всегда так, зависит от кодека видеофайла)
  • Количество кадров в записи может быть слишком большим например час видео при хорошем fps это порядка 200K файлов, что многовато для одного каталога, даже если это ext4. Городить разбиение на поддиректории и потом склеивать при компоновке видео не хотелось усложнять


В итоге вместо ffmpeg появился пятый элемент компонент Framer. Он запускается из vproc, и листает кадры в том же видеофайле, ожидая пока vproc найдет розыгрыши. Как только они появились framer выкладывает нужные кадры в отдельную директорию.
Из дополнительных плюсов ни одного лишнего кадра не эспортируется.
Мелочь, но все таки.

По производительности (на 10-минутном тестовом видео):
Было:
Completed file id=73, for game=test, frames=36718, fps=50, duration=600 in 1677 sec

Стало:
Completed file id=83, for game=test, frames=36718, fps=50, duration=600 in 523 sec + framer time 303


Выигрыш в два раза, очень хорошо. Если писать в несколько потоков, то наверное можно выжать еще чуть. Если хранить кадры в памяти и генерить из них видео через API, то скорее всего можно ускориться значительно.

Digital Ocean


Дальше я стал выбирать хостинг. Понятно, что основные варианты GKE, AWS, Azure, но многие авторы мелких проектов жалуются на непрозрачное ценообразование и, как следствие, немаленькие счета.
Основная засада здесь цена за исходящий трафик, она составляет порядка $100/Tb, а поскольку речь идет о раздаче видео, вероятность серьезно попасть очень неиллюзорна.

Тогда я решил глянуть второй эшелон Digital Ocean, Linode, Heroku. На самом деле Kubernetes-as-service уже не такая редкая вещь, но многие варианты не выглядят user-friendly.

Больше всего понравился Digital Ocean, потому что:
  • Managed Kubernetes
  • Managed Postgres
  • S3 хранилище с бесплатным(!) CDN + 1 TB/месяц
  • Закрытый docker registry
  • Все операции можно делать через API
  • Датацентры по всему миру


При наличии CDN веб-серверу уже не было надобности раздавать видео, однако кто-то должен был это видео опубликовать.
Так в архитектуре появился четвертый компонент Pusher.



Однако серьезным недостатком оказалась невозможность смонтировать один и тот же диск на несколько машин одновременно.

Сам DO предлагает в таких случаях использовать NFS через выделенный контейнер, но пока решил не заморачиваться, ибо, как упоминалось выше, назревает специальное решение для видео кадров, и если уже городить отдельный контейнер, то для него.

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

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

Логи и метрики


Запустив кластер в облаке, я стал искать решение для сбора логов и метрик. Самостоятельно возиться с хостингом этого добра не хотелось, поэтому целью был free-tier в каком-нибудь облаке.
Такое есть не у всех: Модная Grafana хочет $50 в месяц, выходящий из моды Elastic $16, Splunk даже прямо не говорит.
Зато внезапно оказалось что New Relic, также известный своими негуманными ценами, теперь предоставляет первые 100G в месяц бесплатно.

Видео


Вполне ествественным решением виделось разделить кластер на два node-pool'а:

  1. front на котором крутятся веб-сервера
  2. vproc где обрабатывается видео


Фронтовой пул, понятно, всегда онлайн, а вот процессинговый не так прост.
Во-первых, обработка видео требует серьезных ресурсов (= денег), а во-вторых, обрабатывать приходится нечасто (особенно в стадии зарождения проекта).
Поэтому хочется включать процессинг только, чтобы обработать видео (время обработки 3x от продолжительности действа).

Кубернетес формально поддерживает autoscale 0, но как именно это реализуется я не нашел, зато нашлась такая дискуссия на Stack Overflow.

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

У DigitalOcean есть неофициальный клиент для питона, но он уже давно не обновлялся, а Kubernetes API там не присутствует в принципе.

Я сделал на коленке клиент для себя, он покрывает процентов десять возможностей, но вдруг кому пригодится для старта.

В итоге диаграмм разрослась вот так:



DevOps


Несмотря на великое множество CI/CD инструментов, в разработке не нашлось ничего удобнее Jenkins.
А для управления DigitalOcean'ом идеально подошли Github Actions.

Ссылки


Подробнее..

Запуск сложных C приложений на микроконтроллерах

27.01.2021 20:23:33 | Автор: admin
image Сегодня никого не удивить возможностью разрабатывать на C++ под микроконтроллеры. Проект mbed полностью ориентирован на этот язык. Ряд других RTOS предоставляют возможности разработки на С++. Это удобно, ведь программисту доступны средства объектно-ориентированного программирования. Вместе с тем, многие RTOS накладывают различные ограничения на использование C++. В данной статье мы рассмотрим внутреннюю организацию C++ и выясним причины этих ограничений.

Сразу хочу отметить, что большинство примеров будут рассмотрены на RTOS Embox. Ведь в ней на микроконтроллерах работают такие сложные C++ проекты как Qt и OpenCV. OpenCV требует полной поддержки С++, которой обычно нет на микроконтроллерах.

Базовый синтаксис


Синтаксис языка C++ реализуется компилятором. Но в рантайм необходимо реализовать несколько базовых сущностей. В компиляторе они включаются в библиотеку поддержки языка libsupc++.a. Наиболее базовой является поддержка конструкторов и деструкторов. Существуют два типа объектов: глобальные и выделяемые с помощью операторов new.

Глобальные конструкторы и деструкторы


Давайте взглянем на то как работает любое C++ приложение. Перед тем как попасть в main(), создаются все глобальные C++ объекты, если они присутствуют в коде. Для этого используется специальная секция .init_array. Еще могут быть секции .init, .preinit_array, .ctors. Для современных компиляторов ARM, чаще всего секции используются в следующем порядке .preinit_array, .init и .init_array. С точки зрения LIBC это обычный массив указателей на функции, который нужно пройти от начала и до конца, вызвав соответствующий элемент массива. После этой процедуры управление передается в main().

Код вызова конструкторов для глобальных объектов из Embox:

void cxx_invoke_constructors(void) {    extern const char _ctors_start, _ctors_end;    typedef void (*ctor_func_t)(void);    ctor_func_t *func = (ctor_func_t *) &_ctors_start;    ....    for ( ; func != (ctor_func_t *) &_ctors_end; func++) {        (*func)();    }}

Давайте теперь посмотрим как устроено завершение C++ приложения, а именно, вызов деструкторов глобальных объектов. Существует два способа.

Начну с наиболее используемого в компиляторах через __cxa_atexit() (из C++ ABI). Это аналог POSIX функции atexit, то есть вы можете зарегистрировать специальные обработчики, которые будут вызваны в момент завершения программы. Когда при старте приложения происходит вызов глобальных конструкторов, как описано выше, там же есть и сгенерированный компилятором код, который регистрирует обработчики через вызов __cxa_atexit. Задача LIBC здесь сохранить требуемые обработчики и их аргументы и вызвать их в момент завершения приложения.

Другим способом является сохранение указателей на деструкторы в специальных секциях .fini_array и .fini. В компиляторе GCC это может быть достигнуто с помощью флага -fno-use-cxa-atexit. В этом случае во время завершения приложения деструкторы должны быть вызваны в обратном порядке (от старшего адреса к младшему). Этот способ менее распространен, но может быть полезен в микроконтроллерах. Ведь в этом случае на момент сборки приложения можно узнать сколько обработчиков потребуется.

Код вызова деструкторов для глобальных объектов из Embox:

int __cxa_atexit(void (*f)(void *), void *objptr, void *dso) {    if (atexit_func_count >= TABLE_SIZE) {        printf("__cxa_atexit: static destruction table overflow.\n");        return -1;    }    atexit_funcs[atexit_func_count].destructor_func = f;    atexit_funcs[atexit_func_count].obj_ptr = objptr;    atexit_funcs[atexit_func_count].dso_handle = dso;    atexit_func_count++;    return 0;};void __cxa_finalize(void *f) {    int i = atexit_func_count;    if (!f) {        while (i--) {            if (atexit_funcs[i].destructor_func) {                (*atexit_funcs[i].destructor_func)(atexit_funcs[i].obj_ptr);                atexit_funcs[i].destructor_func = 0;            }        }        atexit_func_count = 0;    } else {        for ( ; i >= 0; --i) {            if (atexit_funcs[i].destructor_func == f) {                (*atexit_funcs[i].destructor_func)(atexit_funcs[i].obj_ptr);                atexit_funcs[i].destructor_func = 0;            }        }    }}void cxx_invoke_destructors(void) {    extern const char _dtors_start, _dtors_end;    typedef void (*dtor_func_t)(void);    dtor_func_t *func = ((dtor_func_t *) &_dtors_end) - 1;    /* There are two possible ways for destructors to be calls:     * 1. Through callbacks registered with __cxa_atexit.     * 2. From .fini_array section.  */    /* Handle callbacks registered with __cxa_atexit first, if any.*/    __cxa_finalize(0);    /* Handle .fini_array, if any. Functions are executed in teh reverse order. */    for ( ; func >= (dtor_func_t *) &_dtors_start; func--) {        (*func)();    }}

Глобальные деструкторы необходимы, чтобы иметь возможность перезапускать C++ приложения. Большинство RTOS для микроконтроллеров предполагает запуск единственного приложения, которое не перезагружается. Старт начинается с пользовательской функции main, единственной в системе. Поэтому в небольших RTOS зачастую глобальные деструкторы пустые, ведь их использование не предполагается.

Код глобальный деструкторов из Zephyr RTOS:

/** * @brief Register destructor for a global object * * @param destructor the global object destructor function * @param objptr global object pointer * @param dso Dynamic Shared Object handle for shared libraries * * Function does nothing at the moment, assuming the global objects * do not need to be deleted * * @return N/A */int __cxa_atexit(void (*destructor)(void *), void *objptr, void *dso){    ARG_UNUSED(destructor);    ARG_UNUSED(objptr);    ARG_UNUSED(dso);    return 0;}

Операторы new/delete


В компиляторе GCC реализация операторов new/delete находится в библиотеке libsupc++, А их декларации в заголовочном файле .

Можно использовать реализации new/delete из libsupc++.a, но они достаточно простые и могут быть реализованы например, через стандартные malloc/free или аналоги.

Код реализации new/delete для простых объектов Embox:

void* operator new(std::size_t size)  throw() {    void *ptr = NULL;    if ((ptr = std::malloc(size)) == 0) {        if (alloc_failure_handler) {            alloc_failure_handler();        }    }    return ptr;}void operator delete(void* ptr) throw() {    std::free(ptr);}

RTTI & exceptions


Если ваше приложение простое, вам может не потребоваться поддержка исключений и динамическая идентификация типов данных (RTTI). В этом случае их можно отключить с помощью флагов компилятора -no-exception -no-rtti.

Но если эта функциональность С++ требуется, ее нужно реализовать. Сделать это куда сложнее чем new/delete.

Хорошая новость заключается в том что эти вещи не зависят от ОС и уже реализованы в кросс-компиляторе в библиотеке libsupc++.a. Соответственно, самый простой способ добавить поддержку это использовать библиотеку libsupc++.a из кросс компилятора. Сами прототипы находятся в заголовочных файлах и .

Для использования исключений из кросс-компилятора есть небольшие требования, которые нужно реализовать при добавлении собственного метода загрузки C++ рантайма. В линкер скрипте должна быть предусмотрена специальная секция .eh_frame. А перед использованием рантайма эта секция должна быть инициализирована с указанием адреса начала секции. В Embox используется следующий код:

void register_eh_frame(void) {    extern const char _eh_frame_begin;    __register_frame((void *)&_eh_frame_begin);}

Для ARM архитектуры используются другие секции с собственной структурой информации .ARM.exidx и .ARM.extab. Формат этих секция определяется в стандарте Exception Handling ABI for the ARM Architecture EHABI. .ARM.exidx это таблица индексов, а .ARM.extab это таблица самих элементов требуемых для обработки исключения. Чтобы использовать эти секции для обработки исключений, необходимо включить их в линкер скрипт:

    .ARM.exidx : {        __exidx_start = .;        KEEP(*(.ARM.exidx*));        __exidx_end = .;    } SECTION_REGION(text)    .ARM.extab : {        KEEP(*(.ARM.extab*));    } SECTION_REGION(text)

Чтобы GCC мог использовать эти секции для обработки исключений, указывается начало и конец секции .ARM.exidx __exidx_start и __exidx_end. Эти символы импортируются в libgcc в файле libgcc/unwind-arm-common.inc:
extern __EIT_entry __exidx_start;extern __EIT_entry __exidx_end;

Более подробно про stack unwind на ARM написано в статье.

Стандартная библиотека языка (libstdc++)


Собственная реализация стандартной библиотеки


В поддержку языка C++ входит не только базовый синтаксис, но и стандартная библиотека языка libstdc++. Ее функциональность, так же как и для синтаксиса, можно разделить на разные уровни. Есть базовые вещи типа работы со строками или C++ обертка setjmp . Они легко реализуются через стандартную библиотеку языка C. А есть более продвинутые вещи, например, Standard Template Library (STL).

Стандартная библиотека из кросс-компилятора


Базовые вещи реализованы в Embox. Если этих вещей достаточно, то можно не подключать внешнюю стандартную библиотеку языка C++. Но если нужна, например, поддержка контейнеров, то самым простым способом является использование библиотеки и заголовочных файлов из кросс-компилятора.

При использовании стандартной библиотеки С++ из кросс-компилятора существует особенность. Взглянем на стандартный arm-none-eabi-gcc:

$ arm-none-eabi-gcc -vUsing built-in specs.COLLECT_GCC=arm-none-eabi-gccCOLLECT_LTO_WRAPPER=/home/alexander/apt/gcc-arm-none-eabi-9-2020-q2-update/bin/../lib/gcc/arm-none-eabi/9.3.1/lto-wrapperTarget: arm-none-eabiConfigured with: ***     --with-gnu-as --with-gnu-ld --with-newlib   ***Thread model: singlegcc version 9.3.1 20200408 (release) (GNU Arm Embedded Toolchain 9-2020-q2-update)

Он собран с поддержкой --with-newlib.Newlib реализация стандартной библиотеки языка C. В Embox используется собственная реализация стандартной библиотеки. Для этого есть причина, минимизация накладных расходов. И следовательно для стандартной библиотеки С можно задать требуемые параметры, как и для других частей системы.

Так как стандартные библиотеки C отличаются, то для поддержки рантайма нужно реализовать слой совместимости. Приведу пример реализации из Embox одной из необходимых но неочевидных вещей для поддержки стандартной библиотеки из кросс-компилятора

struct _reent {    int _errno;           /* local copy of errno */  /* FILE is a big struct and may change over time.  To try to achieve binary     compatibility with future versions, put stdin,stdout,stderr here.     These are pointers into member __sf defined below.  */    FILE *_stdin, *_stdout, *_stderr;};struct _reent global_newlib_reent;void *_impure_ptr = &global_newlib_reent;static int reent_init(void) {    global_newlib_reent._stdin = stdin;    global_newlib_reent._stdout = stdout;    global_newlib_reent._stderr = stderr;    return 0;}

Все части и их реализации необходимые для использования libstdc++ кросс-компилятора можно посмотреть в Embox в папке third-party/lib/toolchain/newlib_compat/

Расширенная поддержка стандартной библиотеки std::thread и std::mutex


Стандартная библиотека C++ в компиляторе может иметь разный уровень поддержки. Давайте еще раз взглянем на вывод:

$ arm-none-eabi-gcc -v***Thread model: singlegcc version 9.3.1 20200408 (release) (GNU Arm Embedded Toolchain 9-2020-q2-update)

Модель потоков Thread model: single. Когда GCC собран с этой опцией, убирается вся поддержка потоков из STL (например std::thread и std::mutex). И, например, со сборкой такого сложного С++ приложение как OpenCV возникнут проблемы. Иначе говоря, для сборки приложений, которые требуют подобную функциональность, недостаточно этой версии библиотеки.

Решением, которые мы применяем в Embox, является сборка собственного компилятора ради стандартной библиотеки с многопоточной моделью. В случае Embox модель потоков используется posix Thread model: posix. В этом случае std::thread и std::mutex реализуются через стандартные pthread_* и pthread_mutex_*. При этом также отпадает необходимость подключать слой совместимости с newlib.

Конфигурация Embox


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

В Embox для оптимизации затрат на поддержку введены несколько абстрактных классов (интерфейсов) различные реализации которых можно задать.

  • embox.lib.libsupcxx определяет какой метод для поддержки базового синтаксиса языка нужно использовать.
  • embox.lib.libstdcxx определяет какую реализацию стандартной библиотеки нужно использовать

Есть три варианта libsupcxx:

  • embox.lib.cxx.libsupcxx_standalone базовая реализация в составе Embox.
  • third_party.lib.libsupcxx_toolchain использовать библиотеку поддержки языка из кросс-компилятора
  • third_party.gcc.tlibsupcxx полная сборка библиотеки из исходников

Минимальный вариант может работать даже без стандартной библиотеки С++. В Embox есть реализация базирующаяся на простейших функциях из стандартной библиотеки языка С. Если этой функциональности не хватает, можно задать три варианта libstdcxx.

  • third_party.STLport.libstlportg стандартная библиотека вслкючающая STL на основе проекта STLport. Не требует пересборки gcc. Но проект давно не поддерживается
  • third_party.lib.libstdcxx_toolchain стандартная библиотека из кросс-компилятора
  • third_party.gcc.libstdcxx полная сборка библиотеки из исходников

Если есть желание у нас на wiki описано как можно собрать и запустить Qt или OpenCV на STM32F7. Весь код естественно свободный.
Подробнее..

OpenCV в Python. Часть 1

16.09.2020 22:19:36 | Автор: admin

Привет, Хабр! Запускаю цикл статей по библиотеке OpenCV в Python. Кому интересно, добро пожаловать под кат!


my_logo


Введение


OpenCV это open source библиотека компьютерного зрения, которая предназначена для анализа, классификации и обработки изображений. Широко используется в таких языках как C, C++, Python и Java.


Установка


Будем считать, что Python и библиотека OpenCV у вас уже установлены, если нет, то вот инструкция для установки python на windows и на ubuntu, установка OpenCV на windows и на ubuntu.


Немного про пиксели и цветовые пространства


Перед тем как перейти к практике, нам нужно разобраться немного с теорией. Каждое изображение состоит из набора пикселей. Пиксель это строительный блок изображения. Если представить изображение в виде сетки, то каждый квадрат в сетке содержит один пиксель, где точке с координатой ( 0, 0 ) соответствует верхний левый угол изображения. К примеру, представим, что у нас есть изображение с разрешением 400x300 пикселей. Это означает, что наша сетка состоит из 400 строк и 300 столбцов. В совокупности в нашем изображении есть 400*300 = 120000 пикселей.
В большинстве изображений пиксели представлены двумя способами: в оттенках серого и в цветовом пространстве RGB. В изображениях в оттенках серого каждый пиксель имеет значение между 0 и 255, где 0 соответствует чёрному, а 255 соответствует белому. А значения между 0 и 255 принимают различные оттенки серого, где значения ближе к 0 более тёмные, а значения ближе к 255 более светлые:


4850884 91136851 P7DI0Ak0 greyscalesteps0255


Цветные пиксели обычно представлены в цветовом пространстве RGB(red, green, blue красный, зелёный, синий), где одно значение для красной компоненты, одно для зелёной и одно для синей. Каждая из трёх компонент представлена целым числом в диапазоне от 0 до 255 включительно, которое указывает как много цвета содержится. Исходя из того, что каждая компонента представлена в диапазоне [0,255], то для того, чтобы представить насыщенность каждого цвета, нам будет достаточно 8-битного целого беззнакового числа. Затем мы объединяем значения всех трёх компонент в кортеж вида (красный, зеленый, синий). К примеру, чтобы получить белый цвет, каждая из компонент должна равняться 255: (255, 255, 255). Тогда, чтобы получить чёрный цвет, каждая из компонент должна быть равной 0:
(0, 0, 0). Ниже приведены распространённые цвета, представленные в виде RGB кортежей:
Снимок экрана от 2020-08-31 01-29-26


Импорт библиотеки OpenCV


Теперь перейдём к практической части. Первое, что нам необходимо сделать это импортировать библиотеку. Есть несколько путей импорта, самый распространённый это использовать выражение:


import cv2

Также можно встретить следующую конструкцию для импорта данной библиотеки:


from cv2 import cv2

Загрузка, отображение и сохранение изображения


def loading_displaying_saving():    img = cv2.imread('girl.jpg', cv2.IMREAD_GRAYSCALE)    cv2.imshow('girl', img)    cv2.waitKey(0)    cv2.imwrite('graygirl.jpg', img)

Для загрузки изображения мы используем функцию cv2.imread(), где первым аргументом указывается путь к изображению, а вторым аргументом, который является необязательным, мы указываем, в каком цветовом пространстве мы хотим считать наше изображение. Чтобы считать изображение в RGB cv2.IMREAD_COLOR, в оттенках серого cv2.IMREAD_GRAYSCALE. По умолчанию данный аргумент принимает значение cv2.IMREAD_COLOR. Данная функция возвращает 2D (для изображения в оттенках серого) либо 3D (для цветного изображения) массив NumPy. Форма массива для цветного изображения: высота x ширина x 3, где 3 это байты, по одному байту на каждую из компонент. В изображениях в оттенках серого всё немного проще: высота x ширина.
С помощью функции cv2.imshow() мы отображаем изображение на нашем экране. В качестве первого аргумента мы передаём функции название нашего окна, а вторым аргументом изображение, которое мы загрузили с диска, однако, если мы далее не укажем функцию cv2.waitKey(), то изображение моментально закроется. Данная функция останавливает выполнение программы до нажатия клавиши, которую нужно передать первым аргументом. Для того, чтобы любая клавиша была засчитана передаётся 0. Слева представлено изображение в оттенках серого, а справа в формате RGB:


concatenate_two_girl


И, наконец, с помощью функции cv2.imwrite() записываем изображение в файл в формате jpg(данная библиотека поддерживает все популярные форматы изображений:png, tiff,jpeg,bmp и т.д., поэтому можно было сохранить наше изображение в любом из этих форматов), где первым аргументом передаётся непосредственно само название и расширение, а следующим параметром изображение, которое мы хотим сохранить.


Доступ к пикселям и манипулирование ими


Для того, чтобы узнать высоту, ширину и количество каналов у изображения можно использовать атрибут shape:


print("Высота:"+str(img.shape[0]))print("Ширина:" + str(img.shape[1]))print("Количество каналов:" + str(img.shape[2]))

Важно помнить, что у изображений в оттенках серого img.shape[2] будет недоступно, так как данные изображения представлены в виде 2D массива.
Чтобы получить доступ к значению пикселя, нам просто нужно указать координаты x и y пикселя, который нас интересует. Также важно помнить, что библиотека OpenCV хранит каналы формата RGB в обратном порядке, в то время как мы думаем в терминах красного, зеленого и синего, то OpenCV хранит их в порядке синего, зеленого и красного цветов:


(b, g, r) = img[0, 0]print("Красный: {}, Зелёный: {}, Синий: {}".format(r, g, b))

Cначала мы берём пиксель, который расположен в точке (0,0). Данный пиксель, да и любой другой пиксель, представлены в виде кортежа. Заметьте, что название переменных расположены в порядке b, g и r. В следующей строке выводим значение каждого канала на экран. Как можно увидеть, доступ к значениям пикселей довольно прост, также просто можно и манипулировать значениями пикселей:


img[0, 0] = (255, 0, 0)(b, g, r) = img[0, 0] print("Красный: {}, Зелёный: {}, Синий: {}".format(r, g, b))

В первой строке мы устанавливаем значение пикселя (0, 0) равным (255, 0, 0), затем мы снова берём значение данного пикселя и выводим его на экран, в результате мне на консоль вывелось следующее:


Красный: 251, Зелёный: 43, Синий: 65Красный: 0, Зелёный: 0, Синий: 255

На этом у нас конец первой части. Если вдруг кому-то нужен исходный код и картинка, то вот ссылка на github. Всем спасибо за внимание!

Подробнее..

OpenCV в Python. Часть 2

15.11.2020 22:16:27 | Автор: admin

Привет, Хабр! Продолжаем туториал по библиотеке opencv в python. Для тех кто не читал первую часть, сюда: Часть 1, а всем остальным увлекательного чтения!


part_2_logo


Введение


Теперь, когда вы ознакомились с основами данной библиотеки, пора приступить к базовым преобразованиям изображений: изменение размера, смещение вдоль осей, кадрирование(обрезка), поворот.


Изменение размера изображения


Первый метод, который мы изучим это как поменять высоту и ширину у изображения. Для этого в opencv есть такая функция как resize():


def resizing():    res_img = cv2.resize(img, (500, 900), cv2.INTER_NEAREST)

Данная функция первым аргументом принимает изображение, размер которого мы хотим изменить, вторым кортеж, который должен содержать в себе ширину и высоту для нового изображения, третьим метод интерполяции(необязательный). Интерполяция это алгоритм, который находит неизвестные промежуточные значения по имеющемуся набору известных значений. Фактически, это то, как будут заполняться новые пиксели при модификации размера изображения. К примеру, интерполяция методом ближайшего соседа (cv2.INTER_NEAREST) просто берёт для каждого пикселя итогового изображения один пиксель исходного, который наиболее близкий к его положению это самый простой и быстрый способ. Кроме этого метода в opencv существуют следующие: cv2.INTER_AREA, cv2.INTER_LINEAR( используется по умолчанию), cv2.INTER_CUBIC и cv2.INTER_LANCZOS4. Наиболее предпочтительным методом интерполяции для сжатия изображения является cv2.INTER_AREA, для увелечения cv2.INTER_LINEAR. От данного метода зависит качество конечного изображения, но как показывает практика, если мы уменьшаем/увеличиваем изображение меньше, чем в 1.5 раза, то не важно каким методом интерполяции мы воспользовались качество будет схожим. Данное утверждение можно проверить на практике. Напишем следующий код:


res_img_nearest = cv2.resize(img, (int(w / 1.4), int(h / 1.4)),                                  cv2.INTER_NEAREST)res_img_linear = cv2.resize(img, (int(w / 1.4), int(h / 1.4)),                                 cv2.INTER_LINEAR)

Слева изображение с интерполяцией методом ближайшего соседа, справа изображение с билинейной интерполяцией:


conc_girl


Очень важно при изменении размера изображения учитывать соотношение сторон. Соотношение сторон это пропорциональное соотношение ширины и высоты изображения.Если мы забудем о данном понятии, то получим изображение такого плана:


res_girl


Поэтому текущую функцию для изменения размера необходимо модифицировать:


def resizing(new_width=None, new_height=None, interp=cv2.INTER_LINEAR):    h, w = img.shape[:2]    if new_width is None and new_height is None:        return img    if new_width is None:        ratio = new_height / h        dimension = (int(w * ratio), new_height)    else:        ratio = new_width / w        dimension = (new_width, int(h * ratio))    res_img = cv2.resize(img, dimension, interpolation=interp)

Соотношение сторон мы вычисляем в переменной ratio. В зависимости от того, какой параметр не равен None, мы берём установленную нами новую высоту/ширину и делим на старую высоту/ширину. Далее, в переменной dimension мы определяем новые размеры изображения и передаём в функцию cv2.resize().


Смещение изображения вдоль осей


С помощью функции cv2.warpAffine() мы можем перемещать изображение влево и вправо, вниз и вверх, а также любую комбинацию из перечисленного:


def shifting():    h, w = img.shape[:2]    translation_matrix = np.float32([[1, 0, 200], [0, 1, 300]])    dst = cv2.warpAffine(img, translation_matrix, (w, h))    cv2.imshow('Изображение, сдвинутое вправо и вниз', dst)    cv2.waitKey(0)

Сначала, в переменной translation_matrix мы создаём матрицу преобразований как показано ниже:


1


Первая строка матрицы [1, 0, tx ], где tx количество пикселей, на которые мы будем сдвигать изображение влево или вправо. Отрицательное значения tx будет сдвигать изображение влево, положительное вправо.
Вторая строка матрицы [ 0, 1, ty], где ty количество пикселей, на которые мы будем сдвигать изображение вверх или вниз. Отрицательное значения ty будет сдвигать изображение вверх, положительное вниз. Важно помнить, что данная матрица определяется как массив с плавающей точкой.
На следующей строчке и происходит сдвиг изображения вдоль осей, с помощью, как я писал выше, функции cv2.warpAffine(), которая первым аргументом принимает изображение, вторым матрицу, третьим размеры нашего изображения. Если вы запустите данный код, то увидите следующее:


girl_right_and_down


Вырез фрагмента изображения


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


def cropping():    crop_img = img[10:450, 300:750]

В данной строчке мы предоставляем массив numpy для извлечения прямоугольной области изображения, начиная с (300, 10) и заканчивая (750, 450), где 10 это начальная координата по y, 300 начальная координата по x, 450 конечная координата по y и 750 конечная координата по x.Выполнив код выше, мы увидим, что обрезали лицо девочке:


crop_face


Поворот изображения


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


def rotation():    (h, w) = img.shape[:2]    center = (int(w / 2), int(h / 2))    rotation_matrix = cv2.getRotationMatrix2D(center, -45, 0.6)    rotated = cv2.warpAffine(img, rotation_matrix, (w, h))

Когда мы поворачиваем изображение, нам нужно указать, вокруг какой точки мы будем вращаться, именно это принимает первым аргументом функция cv2.getRotationMatrix2D(). В данном случае я указал центр изображения, однако opencv позволяет указать любую произвольную точку, вокруг которой вы захотите вращаться. Следующим аргументом данная функция принимает угол, на который мы хотим повернуть наше изображение, а последним аргументом коэффициент масштабирования. Мы используем 0.6, то есть уменьшаем изображение на 40%, для того, чтобы оно поместилось в кадр. Данная функция возвращает массив numpy, который мы передаём вторым аргументом в функцию cv2.warpAffine(). В итоге, у вас на экране должно отобразиться следующее изображение:


rotated_girl


Вот и вторая часть подошла к концу, исходный код доступен на github. Спасибо за уделённое внимание! До скорой встречи!

Подробнее..

Разработка приложения с использованием Python и OpenCV на Android устройстве

18.11.2020 22:19:29 | Автор: admin

В это статье я хочу показать пример того, как андроид устройство можно использовать для разработки на таких языках программирования как python с библиотекой opencv в среде VSCode (будет использован code-server). В конце статьи приведено небольшое сравнение производительности Termux на моем Android устройстве и Raspberry Pi 3B.

Все действия описанные статье выполнялись на:
Huawei MediaPad M5 10.8
4GB ОЗУ, Hisilicon Kirin 960s, EMUI 9, без root

Для начала понадобится установить Termux (эмулятор терминала, предоставляющий возможности среды Linux), о придожении уже писали на habr.

Далее установим необходимые пакеты, а так же, для более быстрой настройки в дальнейшем, установим ssh сервер:
$ pkg update -y pkg install curl openssh autossh termux-services screen$ sv-enable sshd$ sv up sshd

Теперь можно воспользоваться более удобным для ввода устройством и выполнять действия по ssh. В данной статье будет рассмотрен способ подключения с использованием логина и пароля, для этого необходимо узнать имя текущего пользователя и задать пароль:
$ whoamiu0_a137$ passwd

По умолчанию openssh прослушивает порт 8022, узнать ip адрес устройства можно с помощью команды ifconfig:
$ ifconfigwlan0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500         inet 192.168.1.88  netmask 255.255.255.0  broadcast 192.168.1.255

Подключаемся к Termux:
$ ssh u0_a137@192.168.1.88 -p 8022 

Установить opencv-python в собственном окружении Termux мне не удалось, поэтому воспользуемся трудами Andronix и запустим в Termux Ubuntu 18.04.
$ curl https://raw.githubusercontent.com/AndronixApp/AndronixOrigin/master/Installer/Ubuntu/ubuntu.sh | bash

В официальном приложением Andronix можно найти команды для установки других дистрибутивов таких как Kali, Manjaro и т.д.
Если все выполнилось успешно на экране появится
You can now launch Ubuntu with the ./start-ubuntu.sh script

Запускаем Ubuntu
$ ./start-ubuntu.sh 

Установим пакеты необходимые для разработки на python3 с использованием opencv:
$ apt update$ apt install curl git net-tools unzip yarn nano nodejs python3-dev python3-pip python3-opencv -y

Установка занимает довольно много времени.
Теперь установим code-server
$ curl -fsSL https://code-server.dev/install.sh | sh

После установки code-server необходимо отредактировать файл конфигурации
$ nano ~/.config/code-server/config.yamlbind-addr: 127.0.0.1:8080auth: passwordpassword: 4a40bd9973dae545b3b4c037cert: false

По умолчанию code-server прослушивает адрес 127.0.0.1:8080, для обращения к code-server с других устройств необходимо поменять bind-addr на 0.0.0.0:8080. Присутствует возможность авторизации по паролю. Для задания пароля необходимо изменить значение password. Для отключения авторизации необходимо указать auth: none.

Все действия в статье выполняются в Ubuntu без графического интерфейса в результате чего нет возможности использовать imshow, но есть способ транслировать видео в браузер используя библиотеку flask и специальный MIME тип multipart.

Чтобы не отвлекаться на написание кода, используемого в статье для примера, его можно взять в моем github репозитории.
$ git clone https://github.com/guinmoon/flask_opencv_sample$ cd flask_opencv_sample && pip3 install flask

Открываем проект в code-server

При первом открытии будет предложено установить расширение Python соглашаемся.
Установим приложение для потоковой передачи с камеры устройства, например IP Webcam. Главное требование к приложению возможность транслировать с камеры поток понятный opencv, например rtsp. Разрешение видео потока настраиваем в зависимости от производительности устройства. На моем самое оптимальное 1280x720.


Запускаем трансляцию видео в IP Webcam и проект:


В заключение хочу отметить, что при наличии современного Android устройства его можно использовать как альтернативу raspberry pi. Так, например, сняв ограничения энергопотребления домашний планшет можно использовать как полноценный arm64 мини пк, работающий в фоне постоянно. При этом производительность у Termux вполне высокая.
Сравнение запуска того же кода на Raspberry pi 3
Разрешение видео 1280x720


Далее несколько дополнений которые не вошли в основную статью

Память устройства
Для того чтобы иметь возможность обмениваться файлами между termux и android необходимо в выполнить команду
$ termux-setup-storage

Теперь локальная память устройства примонтирована в ~/storage

.Net Core
Присутствует возможность компилировать приложения .net-core, но к сожалению без возможности отладки так как нет версии OmniSharp скомпилированной под arm.
в start_ubuntu.sh
ищем строчку
command+=" -b /data/data/com.termux/files/home:/root"
и исправляем ее на
command+=" -b /data/data/com.termux/files/home"
curl -SL -o dotnet-sdk-3.1.403-linux-arm64.tar.gz https://download.visualstudio.microsoft.com/download/pr/7a027d45-b442-4cc5-91e5-e5ea210ffc75/68c891aaae18468a25803ff7c105cf18/dotnet-sdk-3.1.403-linux-arm64.tar

если ссылка не работает то руками качаем от сюда

mkdir -p /usr/share/dotnettar -zxf dotnet-sdk-3.1.403-linux-arm64.tar.gz -C /usr/share/dotnetln -s /usr/share/dotnet/dotnet /usr/bin/dotnetdotnet new console -o appcd appdotnet run


Тестируем производительность с помощью sysbench
В sourses.list добавляем
deb ftp.debian.org/debian buster-backports main
apt-get updateapt install sysbenchsysbench --test=cpu --cpu-max-prime=20000 --num-threads=4 run

Подробнее..

Как я научила свой компьютер играть в пары используя OpenCV и Глубокое обучение

05.01.2021 14:11:03 | Автор: admin

Немного веселья с компьютерным зрением и CNN с маленькой базой данных.

Моё хобби это настольные игры и, поскольку я имею немного знаний о CNN, я решила сделать приложение, что может победить людей в карточной игре. Я хотела построить модель с нуля при помощи моей собственной базы данных, чтобы посмотреть, насколько хороша модель выйдет с нуля с маленькой базой данных. Было принято решение начать с не слишком сложной игры, Spot it! (она же, Пары).

В случае, если вы всё ещё не знаете об этой игре, вот короткое пояснение: Пары это простая игра на распознавание образов, в которой игроки пытаются найти изображения на двух карточках. В оригинальном Spot it!, на каждой карточке находятся по восемь картинок с разницей в размере между разными карточками. Любые две карточки имеют ровно по одной общей картинке. Если вы находите её первым, вы выигрываете карту. Как только колода из 55 карточек заканчивается, победа присуждается тому, у кого окажется больше карт.

Попробуйте сами: какой общий символ на карточках, показанных выше?Попробуйте сами: какой общий символ на карточках, показанных выше?

С чего начать?

Первым шагом в любом data science исследовании является сбор данных. Я сделала несколько фото на свой телефон, по шесть фото каждой карты. Итого у меня 330 картинок. Четыре из них показаны ниже. Вы можете подумать: а этого достаточно для создания полноценной Свёрточной Нейронной Сети (CNN)? Вернёмся к этому позже!

Обработка изображений

Хорошо, мы имеем данные, что дальше? Возможно, это самый важный пункт в пути к успеху: обработка изображений. Нам нужно обработать картинки, показанные на каждой карточке. Здесь возникает небольшие трудности. Вы можете видеть, что некоторые картинки достать сложнее: снеговик, привидение (третья картинка) и игла (четвёртая картинка) имеют яркий цвет, а пятна (вторая картинка) и восклицательный знак (четвёртая картинка) состоят из нескольких частей. После этого мы изменяем и сохраняем картинку.

Добавляем контраст

Мы используем цветовую систему Lab для изменения контраста. L означает яркость, a обозначает соотношение зелёного к фиолетовому, а b голубого к жёлтому. Мы легко можем извлечь эти компоненты при помощи OpenCV:

import cv2import imutilsimgname = 'picture1'image = cv2.imread(f{imgname}.jpg)lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)l, a, b = cv2.split(lab)
Слева направо: оригинальное изображение, световая компонента, a компонента и b компонентаСлева направо: оригинальное изображение, световая компонента, a компонента и b компонента

Сейчас мы добавим контрастности к световой компоненте, сольём компоненты обратно и конвертируем картинку:

clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8,8))cl = clahe.apply(l)limg = cv2.merge((cl,a,b))final = cv2.cvtColor(limg, cv2.COLOR_LAB2BGR)
Слева направо: оригинальное изображение, световая компонента, с увеличенным контрастом, конвертированная обратно в RGBСлева направо: оригинальное изображение, световая компонента, с увеличенным контрастом, конвертированная обратно в RGB

Масштабирование

Потом мы масштабируем и сохраняем картинку:

resized = cv2.resize(final, (800, 800))# сохраним изображениеcv2.imwrite(f'{imgname}processed.jpg', blurred)

Готово!

Обнаружение карточек и картинок

Сейчас картинки обработаны и мы можем начать с нахождения образов на фото. Можно найти их внешние контуры при помощи OpenCV. Затем надо будет конвертировать изображение в чёрно-белое, выбрать порог (в нашем случае, 190), чтобы найти контуры. В коде:

image = cv2.imread(f{imgname}processed.jpg)gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)thresh = cv2.threshold(gray, 190, 255, cv2.THRESH_BINARY)[1]# ищем контурыcnts = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)cnts = imutils.grab_contours(cnts)output = image.copy()# рисуем контуры на картинкеfor c in cnts:    cv2.drawContours(output, [c], -1, (255, 0, 0), 3)
Обрабатываемое изображение, конвертированное в чёрно-белое, разделённое по порогу и с контурамиОбрабатываемое изображение, конвертированное в чёрно-белое, разделённое по порогу и с контурами

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

# сортируем по площади, берём наибольшуюcnts = sorted(cnts, key=cv2.contourArea, reverse=True)[0]# создаём маску по наибольшему контуруmask = np.zeros(gray.shape,np.uint8)mask = cv2.drawContours(mask, [cnts], -1, 255, cv2.FILLED)# карточку на передний планfg_masked = cv2.bitwise_and(image, image, mask=mask)# белый фон (используем инвертированную маску)mask = cv2.bitwise_not(mask)bk = np.full(image.shape, 255, dtype=np.uint8)bk_masked = cv2.bitwise_and(bk, bk, mask=mask)# сливаем фон и передний планfinal = cv2.bitwise_or(fg_masked, bk_masked)
Маска, фон, передний план, объединённоеМаска, фон, передний план, объединённое

А сейчас пора выделять картинки! Мы можем использовать предидущее изображение, чтобы вновь определить внешние контуры они и есть картинки. Если мы выделим площадь вокруг каждой картинки, мы сможем извлечь её. В этот раз код немножко длиннее:

# прямо как и в предидущем случае (с удалением карточки)gray = cv2.cvtColor(final, cv2.COLOR_RGB2GRAY)thresh = cv2.threshold(gray, 195, 255, cv2.THRESH_BINARY)[1]thresh = cv2.bitwise_not(thresh)cnts = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)cnts = imutils.grab_contours(cnts)cnts = sorted(cnts, key=cv2.contourArea, reverse=True)[:10]# обрабатываем каждый контурi = 0for c in cnts:    if cv2.contourArea(c) > 1000:        # рисуем маску, оставляем контур        mask = np.zeros(gray.shape, np.uint8)        mask = cv2.drawContours(mask, [c], -1, 255, cv2.FILLED)        # белый фон        fg_masked = cv2.bitwise_and(image, image, mask=mask)        mask = cv2.bitwise_not(mask)        bk = np.full(image.shape, 255, dtype=np.uint8)        bk_masked = cv2.bitwise_and(bk, bk, mask=mask)        finalcont = cv2.bitwise_or(fg_masked, bk_masked)        # ограничивающая область по контуру        output = finalcont.copy()        x,y,w,h = cv2.boundingRect(c)        # squares io rectangles        if w &lt; h:            x += int((w-h)/2)            w = h        else:            y += int((h-w)/2)            h = w        # вырезаем область с картинкой        roi = finalcont[y:y+h, x:x+w]        roi = cv2.resize(roi, (400,400))        # сохраняем картинку        cv2.imwrite(f"{imgname}_icon{i}.jpg", roi)        i += 1
Разделённое по порогу, с определёнными контурами, картинки призрака и сердца (вырезанные по маске)Разделённое по порогу, с определёнными контурами, картинки призрака и сердца (вырезанные по маске)

Сортировка картинок

Сейчас начинается скучная часть! Время сортировки картинок. Нам нужны папки теста, трейна и валидации, содержащие по 57 подпапок каждая (у нас 57 различных картинок). Структура каталога выглядит так:

symbols  test     anchor     apple       ...     zebra  train     anchor     apple       ...     zebra  validation      anchor      apple        ...      zebra

Потребуется время, Чтобы поместить все извлечённые картинки в нужные каталоги (более 2500)! У меня есть код для создания подпапок, набор тестов и проверок на GitHub. Может, в следующий раз будет лучше провести сортировку алгоритмом кластеризации

Обучение Свёрточной Нейронной Сети (CNN)

После скучной части наступает крутая часть. Давайте сделаем и обучим CNN. Вы можете найти информацию о CNN в этом посте.

Архитектура модели

Это задача многоклассовой классификации с одной меткой. Нам нужна одна метка для каждого символа. Вот почему необходимо выбрать softmax активации последнего уровня с 57 нейронами и категориальной функцией потерь перекрёстной энтропии.

Архитектура итоговой модели выглядит так:

# импортfrom keras import layersfrom keras import modelsfrom keras import optimizersfrom keras.preprocessing.image import ImageDataGeneratorimport matplotlib.pyplot as plt# слои, активационный слой с 57 нейронами (по одному на каждый символ)model = models.Sequential()model.add(layers.Conv2D(32, (3, 3), activation='relu', input_shape=(400, 400, 3)))model.add(layers.MaxPooling2D((2, 2)))  model.add(layers.Conv2D(64, (3, 3), activation='relu'))model.add(layers.MaxPooling2D((2, 2)))model.add(layers.Conv2D(128, (3, 3), activation='relu'))model.add(layers.MaxPooling2D((2, 2)))model.add(layers.Conv2D(256, (3, 3), activation='relu'))model.add(layers.MaxPooling2D((2, 2)))model.add(layers.Conv2D(256, (3, 3), activation='relu'))model.add(layers.MaxPooling2D((2, 2)))model.add(layers.Conv2D(128, (3, 3), activation='relu'))model.add(layers.Flatten())model.add(layers.Dropout(0.5)) model.add(layers.Dense(512, activation='relu'))model.add(layers.Dense(57, activation='softmax'))model.compile(loss='categorical_crossentropy',       optimizer=optimizers.RMSprop(lr=1e-4), metrics=['acc'])

Аугментация данных

Для лучшей производительности я использовала аугментацию данных. Аугментация данных это процесс увеличения количества и разнообразия входных данных. Это возможно путем поворота, сдвига, масштабирования, обрезки и отражения существующих изображений. Аугментацию данных легко выполнить с Keras:

# определим папкиtrain_dir = 'symbols/train'validation_dir = 'symbols/validation'test_dir = 'symbols/test'# аугментация данных при помощи ImageDataGenerator из Keras (только для тренировки)train_datagen = ImageDataGenerator(rescale=1./255, rotation_range=40, width_shift_range=0.1, height_shift_range=0.1, shear_range=0.1, zoom_range=0.1, horizontal_flip=True, vertical_flip=True)test_datagen = ImageDataGenerator(rescale=1./255)train_generator = train_datagen.flow_from_directory(train_dir, target_size=(400,400), batch_size=20, class_mode='categorical')validation_generator = test_datagen.flow_from_directory(validation_dir, target_size=(400,400), batch_size=20, class_mode='categorical')

Если вам интересно, аугментирование привидения выглядит так:

Оригинальное привидение слева, на других изображениях примеры аугментирования данныхОригинальное привидение слева, на других изображениях примеры аугментирования данных

Подгон модели

Пора бы подогнать модель, сохранить ее для прогнозов и проверить результаты.

history = model.fit_generator(train_generator, steps_per_epoch=100, epochs=100, validation_data=validation_generator, validation_steps=50)# не забывайте сохранить вашу модельmodel.save('models/model.h5')

Полученные результаты

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

Результаты базовой моделиРезультаты базовой модели

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

Результаты итоговой моделиРезультаты итоговой модели

На тестовом наборе эта модель допустила только одну ошибку: она назвала каплю вместо бомбы. Я решила остановиться на модели, точность которой составила 0,995 на тестовом наборе.

Найдите общую картинку двух карточек

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

  • Что-то пошло не так: не найдено общих картинок.

  • На ровно одна общая картинка (может быть правильной или неправильной).

  • Есть больше одной общей картинки. В данном случае я выбрала картинку с наибольшей вероятностью (среднее значение обоих прогнозов).

Код находится на GitHub для прогнозирования всех комбинаций двух изображений в каталоге, файле main.py.

Некоторые результаты:


Заключение

Это идеальная модель? К сожалению, нет! Когда я сделала новые снимки карточек и дала модели найти общий символ, у неё были некоторые проблемы со снеговиком. Иногда она называла снеговиком глаз или зебру! Это дает несколько странные результаты:

Снеговик? Где?Снеговик? Где?

Эта модель лучше людей? Это зависит от обстоятельств: люди могут делать это идеально, но модель работает быстрее! Я рассчитала при помощи компьютера: я дала ему колоду из 55 карт и спросила общий символ для каждой комбинации из двух карт. Всего 1485 комбинаций. Это заняло у компьютера менее 140 секунд. Компьютер допустил несколько ошибок, но по скорости он точно превзойдет любого человека!

Я не думаю, что создать 100%-ную модель действительно сложно. Это может быть сделано, например, с использованием трансферного обучения. Чтобы понять, что делает модель, мы можем визуализировать слои для тестового изображения. Что попробовать в следующий раз!


Надеюсь, вам понравилось читать этот пост!

Подробнее..

OpenCV в Python. Часть 3

26.01.2021 00:16:12 | Автор: admin

Привет, Хабр! Это продолжение туториала по библиотеке opencv в python. Для тех кто не читал первую и вторую части, сюда: Часть 1 и Часть 2, а всем остальным приятного чтения!



Введение


В данной части мы рассмотрим арифметику изображений, разделение и слияние каналов, различные методы размытия.


Арифметика изображений


Надеюсь, что все знают такие арифметические операции как сложение и вычитание, но при работе с изображениями мы не должны забывать о типе данных.
К примеру, у нас есть RGB изображение, пиксели которого попадают в диапазон [0,255]. Итак, что же произойдёт, если мы попытаемся к пикселю с интенсивностью 250 прибавить 30 или от 70 отнять 100? Если бы мы пользовались стандартными арифметическими правилами, то получили бы 280 и -30 соответственно. Однако, если мы работаем с RGB изображениями, где значения пикселей представлены в виде 8-битного целого беззнакового числа, то 280 и -30 не является допустимыми значениями. Для того, чтобы разобраться, что же произойдёт, давайте посмотрим на строчки кода ниже:


print("opencv addition: {}".format(cv2.add(np.uint8([250]),                                                    np.uint8([30]))))print("opencv subtract: {}".format(cv2.subtract(np.uint8([70]),                                                     np.uint8([100]))))print("numpy addition: {}".format(np.uint8([250]) + np.uint8([30])))print("numpy subtract: {}".format(np.uint8([70]) - np.uint8([71])))

Как мы видим, сложение и вычитание можно осуществить с помощью функций opencv add и subtract соответственно, а также с помощью numpy. И результаты будут отличаться:


opencv addition: 255opencv subtract: 0numpy addition: 24numpy subtract: 255

OpenCV выполняет обрезку и гарантирует, что значения пикселей никогда не выйдут за пределы диапазона [0,255]. В numpy же всё происходит немного иначе. Представьте себе обычные настенные часы, где вместо 60 находится 255. Получается, что после достижение 255 следующим числом будет идти 0, а когда мы отнимаем от меньшего числа большее, то после 0 ( против часовой стрелки) будет идти 255.


Разбиение и слияние каналов


Как мы знаем, RGB изображение состоит из красной, зелёной и синих компонент. И что, если мы захотим разделить изображение на соответствующие компоненты? Для этого в opencv есть специальная функция split():


image = cv2.imread('rectangles.png')b, g, r = cv2.split(image)cv2.imshow('blue', b)cv2.imshow('green', g)cv2.imshow('red', r)

Сначала мы загружаем изображение. Для наглядности работы данной функции я взял следующее изображение:



Затем разделяем изображение на три канала и показываем каждый канал по отдельности. В результате выполнения данной функции отобразится три изображения в оттенках серого:



Как мы видим, для каждого изображения каждого канала только прямоугольник с тем же цветом отображается белым. Вот как будет выглядеть каждая компонента для девушки из предыдущих частей:



Как можно увидеть, красный канал очень светлый. Это происходит потому, что оттенки красного очень сильно представлены в нашем изображении. Синий и зелёный каналы, наоборот, очень тёмные. Это случается потому, что на данном изображении очень мало данных цветов.
Для того, чтобы объединить каналы воедино, достаточно воспользоваться функцией merge(), которая принимает значения каналов:


merge_image = cv2.merge([g,b,r])cv2.imshow('merge_image', merge_image)cv2.imshow('original', image)cv2.waitKey(0)


Таким образом, мы получаем такое же изображение как оригинальное, за исключением того, что я поменял местами синий с зелёным каналом.


Размытие


Размытие это когда более резкие области на изображении теряют свою детализацию, в результате чего изображение становится менее чётким. В opencv имеются следующие основные методы размытия: averaging(усреднённое), gaussian(гауссово) и median(медианное).


Averaging


Данный фильтр делает операцию свёртки на изображении с неким ядром, где свёртка это вычисление нового значения пикселя, при котором учитываются значения соседних пикселей. Ядро свёртки это квадратная матрица, где пиксель в центре этой матрицы затем устанавливается как среднее значение всех других пикселей, окружающих его. Для того, чтобы воспользоваться данным размытием достаточно вызвать метод blur(), который принимает изображение и кортеж, с указанием размера ядра:


def averaging_blurring():    image = cv2.imread('girl.jpg')    img_blur_3 = cv2.blur(image, (3, 3))    img_blur_7 = cv2.blur(image, (7, 7))    img_blur_11 = cv2.blur(image, (11, 11))

Чем больше размер ядра, тем более размытым будет становиться изображение:



Gaussian


Гауссово размытие похоже на предыдущее размытие, за исключением того, что вместо простого среднего мы теперь используем взвешенное среднее, где соседние пиксели, которые ближе к центральному пикселю, вносят больший вклад в среднее. Конечным результатом является то, что наше изображение размыто более естественно:



Это размытие реализуется в opencv с помощью функции GaussianBlur(), которая принимает первые два аргумента такие же как и предыдущая функция, а третьим аргументом указываем стандартное отклонение ядра Гаусса. Установив это значение в 0, мы тем самым говорим opencv автоматически вычислять его, в зависимости от размера нашего ядра:


def gaussian_blurring():    image = cv2.imread('girl.jpg')    img_blur_3 = cv2.GaussianBlur(image, (3, 3), 0)    img_blur_7 = cv2.GaussianBlur(image, (7, 7), 0)    img_blur_11 = cv2.GaussianBlur(image, (11, 11), 0)

Median


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


def median_blurring():    image = cv2.imread('girl.jpg')    img_blur_3 = cv2.medianBlur(image, 3)    img_blur_7 = cv2.medianBlur(image, 7)    img_blur_11 = cv2.medianBlur(image, 11)

В результате у нас получится следующее:



На этом данная часть подошла к концу. Код, как всегда, доступен на github. До скорой встречи:)

Подробнее..

Уроки компьютерного зрения на Python OpenCV с самых азов

31.01.2021 22:19:51 | Автор: admin
В этом цикле уроков я расскажу о том, как использовать библиотеку OpenCV в языке Python. Но для начала несколько слов о самом компьютерном зрении. Как компьютер вообще видит? Если подключить к нему видеокамеру, это еще не значит, что он будет видеть. Мы получим просто набор нулей и единиц. А человек видит что-то осмысленное. Как же из этих нулей и единиц извлечь что-то осмысленно? В этом и состоит задача компьютерного зрения.

Как правило, анализ изображения алгоритмами компьютерного зрения проходит следующие этапы (но некоторых этапов может и не быть):
1. Предобработка изображения. На этом этапе может происходить улучшения качества изображения, такое как увеличение контрастности, повышение резкости или наоборот, размытие изображения, чтобы удалить из него шумы и мелкие незначительные детали. Все это нужно для того, чтобы в дальнейшем было легче производить анализ изображения.
2. Промежуточная фильтрация. На этом этапе к изображению применяют различные фильтры, для того, чтобы обозначить на изображения области интереса или облегчить работу специальным алгоритмам анализа изображения.
3. Выявление специальных признаков (фич). Это может быть выделение особых точек, выделение контуров или еще каких-либо признаков.
4. Высокоуровневый анализ. На этом этапе по найденным признакам на изображения определяться конкретные объекты, и, как правило, их координаты. Так же на этом этапе может происходить сегментация либо какая-то иная высокоуровневая обработка.

Ну а теперь перейдем к делу. Мы рассмотрим работу с Python + OpenCV в среде PyCharm. Сначала нам надо установить OpenCV. Для этого идем в ImportSettings:



Далее в ProjectInterpreterи там жмем на плюсик:



Ищем там opencv и устанавливаем его:



Теперь напишем наш Hello, World программу, которая отобразит картинку:

import cv2my_photo = cv2.imread('MyPhoto.jpg')cv2.imshow('MyPhoto', my_photo)cv2.waitKey(0)cv2.destroyAllWindows()


Вот такое вот окно откроет данная программа:



Что делает программа? Она загружает изображение из файла, отображает его и ждет нажатие клавиши ESC для завершения работы.
Давайте попробуем что-нибудь сделать с этим изображением. Например, можно изменить его размер. Допустим, мы хотим сделать изображение шириной 200. Для этого вычислим его высоту, и применим эти данные для масштабирования:

import cv2my_photo = cv2.imread('MyPhoto.jpg')cv2.imshow('MyPhoto', my_photo)#Подготовим новые размерыfinal_wide = 200r = float(final_wide) / my_photo.shape[1]dim = (final_wide, int(my_photo.shape[0] * r))# уменьшаем изображение до подготовленных размеровresized = cv2.resize(my_photo, dim, interpolation = cv2.INTER_AREA)cv2.imshow("Resized image", resized)cv2.waitKey(0)cv2.destroyAllWindows()


Вот что у нас получилось:



Часто для облегчения анализа изображения требуется сделать картинку черно-белой. Один из способов это загрузить картинку сразу в черно-белом цветом пространстве:

import cv2img = cv2.imread('MyPhoto.jpg', cv2.IMREAD_GRAYSCALE)cv2.imshow('MyPhoto', img)cv2.waitKey(0)cv2.destroyAllWindows()


Если нужно преобразовать уже загруженную картинку, то можно воспользоваться функцией cvtColor, которая преобразует изображение из одного цветового пространства в другое:

import cv2img = cv2.imread('MyPhoto.jpg', cv2.IMREAD_GRAYSCALE)cv2.imshow('MyPhoto', img)cv2.waitKey(0)cv2.destroyAllWindows()


Вот как это будет выглядеть:



Итак, подытожим.
Мы научились устанавливать OpenCV, загружать из файла и отображать изображение, а так же делать в нем простейшие преобразования, такие как масштабирования и конвертацию цветного изображения в черно белое. Если данный урок зайдет то будет продолжение.
Подробнее..
Категории: Python , Opencv

Перегон картинок из 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 работает с памятью.

Подробнее..

OpenCV в Python. Часть 4

15.03.2021 22:22:10 | Автор: admin

Привет, Хабр! В этой статье я бы хотел рассказать как с помощью только OpenCV распознавать объекты, на примере игральных карт:



Введение


Допустим, у нас имеется следующее изображение с картами:



А также у нас имеются эталонные изображения каждой карты:



И теперь для того, чтобы детектировать каждую карту, нам необходимо написать три ключевые функции, которые:


  • находит контуры всех карт;
  • находит координаты каждой отдельной карты;
  • распознаёт карту с помощью ключевых точек.

Нахождение контуров карт


def find_contours_of_cards(image):    blurred = cv2.GaussianBlur(image, (3, 3), 0)    T, thresh_img = cv2.threshold(blurred, 215, 255,                                   cv2.THRESH_BINARY)    (_, cnts, _) = cv2.findContours(thresh_img,                                 cv2.RETR_EXTERNAL,                                cv2.CHAIN_APPROX_SIMPLE)    return cnts

Первым аргументом в данную функцию мы передаём изображение в оттенках серого, к которому применяем гауссово размытие, для того, чтобы было легче найти контуры карт. После этого с помощью функции threshold() мы преобразуем наше серое изображение в бинарное. Данная функция принимает первым параметром изображение, вторым пороговое значение, третьим это максимальное значение, которое присваивается значениям пикселей, превышающим пороговое значение. Из нашего примера следует, что любое значение пикселя, превышающее 215, устанавливается равным 255, а любое значение, которое меньше 215, устанавливается равным нулю. И последним параметром передаём метод порогового значения. Мы используем THRESH_BINARY(), который указывает на то, что значения пикселей, которые больше 215 устанавливаются в максимальное значение, которое мы передали третьим параметром. Данная функция возвращает два значения, где первое это значение, которое мы передали в данную функцию вторым аргументом, а второе чёрно-белое изображение, которое выглядит подобным образом:



Теперь мы можем найти контуры наших карт, где контур это кривая, соединяющая все непрерывные точки, которые имеют одинаковый цвет. Поиск контуров осуществляется с помощью метода findContours(), где в качестве первого аргумента эта функция принимает изображение, вторым это тип контуров, который мы хотим извлечь. Я использую cv2.RETR_EXTERNAL для извлечения только внешних контуров. К примеру, для того, чтобы извлечь все контуры используют cv2.RETR_LIST, а последний параметром мы указываем метод аппроксимации контура. Мы используем cv2.CHAIN_APPROX_SIMPLE, указывая на то, что все лишние точки будут удалены, тем самым экономя память. Например, если вы нашли контур прямой линии, то разве вам нужны все точки этой линии, чтобы представить эту линию? Нет, нам нужны только две конечные точки этой линии. Это как раз то, что и делает cv2.CHAIN_APPROX_SIMPLE.


Нахождение координат карт


def find_coordinates_of_cards(cnts, image):    cards_coordinates = {}    for i in range(0, len(cnts)):        x, y, w, h = cv2.boundingRect(cnts[i])        if w > 20 and h > 30:            img_crop = image[y - 15:y + h + 15,                             x - 15:x + w + 15]            cards_name = find_features(img_crop)            cards_coordinates[cards_name] = (x - 15,                      y - 15, x + w + 15, y + h + 15)    return cards_coordinates

Данная функция принимает контуры, которые мы нашли в предыдущей функции, а также основное изображение в оттенках серого. Первым делом мы создаём словарь, где в роли ключа будет выступать название карты, а в роли значения координаты каждой карты. Далее мы проходимся в цикле по нашим контурам, где с помощью функции boundingRect() находим ограничительные рамки каждого контура: начальные x и y координаты, за которыми следуют ширина и высота рамки. Так получилось, что функция, которая искала контура, нашла аж 31 контур, хотя карт всего 4. Это могут быть незначительные контуры, которые мы дальше сортируем в условии, исходя из размера контура.


Теперь, зная координаты контура, мы можем вырезать по этим координатам каждую карту, что собственно мы и делаем в следующей строчке кода. Далее вырезанное изображение мы передаём в функцию find_features()(о ней мы поговорим ниже), которая опираясь на ключевые точки, возвращает нам название вырезанной карты. После этого добавляем всё в словарь.


Распознавание карт


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



Теперь закройте глаза и попытайтесь представить это изображение:



И вы увидите, что помните много деталей об этом образе. Причина этого в том, что на изображении много интересных областей. Теперь переходим к практике:


def find_features(img1):    correct_matches_dct = {}    directory = 'images/cards/sample/'    for image in os.listdir(directory):        img2 = cv2.imread(directory+image, 0)        orb = cv2.ORB_create()        kp1, des1 = orb.detectAndCompute(img1, None)        kp2, des2 = orb.detectAndCompute(img2, None)        bf = cv2.BFMatcher()        matches = bf.knnMatch(des1, des2, k=2)        correct_matches = []        for m, n in matches:            if m.distance < 0.75*n.distance:                correct_matches.append([m])                correct_matches_dct[image.split('.')[0]]                    = len(correct_matches)    correct_matches_dct =        dict(sorted(correct_matches_dct.items(),             key=lambda item: item[1], reverse=True))    return list(correct_matches_dct.keys())[0]

Для обнаружения ключевых точек я использую ORB, который мы инициализируем с помощью вызова функции ORB_create(), после чего мы находим ключевые точки и дискрипторы(которые кодируют интересную информацию в ряд чисел) для эталонной карты и для карты, которую мы вырезали из главного изображения. Вот, к примеру, как выглядят ключевые точки для короля:



Затем нам необходимо сопоставить(вычислить расстояние) дискрипторы первого изображения с дискрипторами второго и взять ближайший. Для этого мы создаём BFMatcher объект с помощью вызова метода BFMatcher(). Теперь мы можем с помощью функции knnMatch() найти k лучших совпадений для каждого дескриптора, где k в нашем случае равно 2. Далее нам необходимо выбрать только хорошие совпадения, основываясь на расстоянии. Поэтому мы проходимся в цикле по нашим совпадениям и если оно удовлетворяет условию m.distance < 0.75*n.distance, то мы засчитываем это совпадение как хорошее и добавляем в список. Потом считаем количество хороших совпадений(чем больше, тем лучше) и основываясь на этом делаем вывод, что за карта. Вот какие совпадения были найдены для каждой карты с королём:



И вслед за этим рисуем прямоугольник вокруг карты с помощью функции draw_rectangle_aroud_cards():


def draw_rectangle_aroud_cards(cards_coordinates, image):    for key, value in cards_coordinates.items():        rec = cv2.rectangle(image, (value[0], value[1]),                             (value[2], value[3]),                             (255, 255, 0), 2)        cv2.putText(rec, key, (value[0], value[1] - 10),                     cv2.FONT_HERSHEY_SIMPLEX,                     0.5, (36, 255, 12), 1)    cv2.imshow('Image', image)    cv2.waitKey(0)

На этом всё. Надеюсь, было познавательно) Код и картинки можно найти на github. До новых встреч:)

Подробнее..

Распознавание волейбольного мяча на видео с дрона

14.06.2021 16:14:45 | Автор: admin

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

Основным фидбеком было - что за каменный век использовать олдскульные технологии, обучаешь нейросеть и погнали. Я пробовал, но не взлетело - очень часто мяч настолько размыт, что его даже человеческим глазом не отличить от случайной кляксы, и даже простой бинарный классификатор не дает стабильных результатов, чего уже говорить о детекторах и прочих YOLO.

В общем, я это дело подзабросил, но вот весной завел себе дрон и конечно же первым делом приспособил его для волейбольных съемок. Если поднять его на нужную высоту (12-15 метров), в кадр влезает вся площадка, что дает прямо неограниченные возможности для анализа.

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

Шум и никакого мячаШум и никакого мяча

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

Для сравнения - похожая подача при старом подходе выглядит вот так:

Делать нечего, придется искать другие подходы. У нас есть OpenCV, так что далеко идти все равно не придется. Вместо фона выделяем границы помощью фильтра Канни - и у нас есть черно-белый скетч для дальнейшего разбора.

      gray = cv.cvtColor(frame, cv.COLOR_BGR2GRAY)      gray = cv.GaussianBlur(gray, (5, 5),0)      mask = cv.Canny(gray, 50, 100)

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

      mask = backSub.apply(frame)      mask = cv.dilate(mask, None)      mask = cv.GaussianBlur(mask, (15, 15),0)      ret,mask = cv.threshold(mask,0,255,cv.THRESH_BINARY | cv.THRESH_OTSU)

И опять же для сравнения - тот же фрагмент с неподвижной камеры с границами:

Как видно, палка имеет два конца - на трясущемся дроне Канни-фильтр дает лучшие результаты, а на статичной камере - генерит лишний фон, на котором теряется мяч.

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

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

Результат - гораздо лучше, шума меньше (но есть еще) и траектория мяча распознается достаточно четко.

Прошлые статьи на эту же тему

Подробнее..

Доббль практичный подход с OpenCV и NumPy

13.01.2021 18:13:38 | Автор: admin

О чём мы вспоминаем в первую очередь, когда слышим про распознавание образов? Сложные нейронные сети, мощные видеокарты, объёмные наборы данных. Всего этого не будет в моей истории - я расскажу, как с помощью OpenCV и NumPy можно за 1 вечер решить задачу классификации 57 символов из игры Доббль, используя менее 500 их изображений без дополнительной аугментации. Разный масштаб, произвольный угол поворота - всё это не имеет значения, когда для описания символа достаточно четырёх чисел.

Эта история произошла весной 2020 года, во время вынужденной самоизоляции. Я смотрел ролики на youtube и наткнулся на интересную игру - Доббль, или по-другому SpotIt. В местных магазинах я вряд ли смог бы её найти, а в условиях самоизоляции вариант с заказом тоже выглядел довольно призрачно. В результате нашёл в сети файл с изображениями карточек, распечатал на плотной фотобумаге и вырезал - получился довольно аккуратный набор. Сыну игра понравилась, стали играть.

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

И тут на Хабре обнаружилась статья "Как я научила свой компьютер играть в Доббль с помощью OpenCV и Deep Learning". Казалось бы, вот оно - решение, но Проанализировав код, я понял, что у подобного решения есть два фатальных недостатка - нарезка и разметка такого количества картинок займет слишком много времени, а тренировка модели на машине с неподдерживаемой видеокартой продлится еще дольше. Стал думать.

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

На самом деле, механизм определения прямоугольника минимального размера в OpenCV не всегда правильно срабатывает - это видно на примере картинки с якорем: она периодически скатывается в неоптимальный симметричный квадрат.

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

Один из неудачных вариантов автоматической разметкиОдин из неудачных вариантов автоматической разметки

Потом я обучил нейросеть - многослойный перцептрон (MLP). Сеть сделал на основе учебного примера из книги "Python Machine Learning" Себастьяна Рашки, для её реализации достаточно пакета NumPy.

В качестве входных данных использовал список файлов с символами - содержащейся в нём информации достаточно для обучения сети. Название папки с символами начинается с двузначного числа - номер символа, используем его как метку. Имя файла содержит 4 числа, соответствующие параметрам символа. Значение всех параметров оказалось в пределах 45..255, поэтому для полного использования диапазона 0..1 вычитаем из них 45 и делим на 210. Так как данных мало, в качестве тестового набора используем часть тренировочного. В списке 440 файлов, время обучения составило около 1 минуты.

И всё равно в результате проверки идеи выявились коллизии, не дающие правильно опознать символ. Пришлось добавить еще один параметр - отношение площади символа к площади описывающего его прямоугольника (я назвал его "плотность"). В результате этот параметр оказался очень полезным, от одного из цветовых каналов удалось избавиться. Единичные символы теперь распознавались устойчиво по четырём признакам.

Казалось бы, можно брать файл с карточкой, распознавать первые 8 самых крупных символов и получится описание карточки. Но тут выявилась другая проблема - некоторые символы состоят из нескольких отдельных частей, а с учётом разного масштаба мелкая часть одного может оказаться крупнее другого. Пришлось добавить в набор данных символы точек от восклицательного и вопросительного знаков и пропускать их при подсчете найденных символов.

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

Проверка работы нейросетиПроверка работы нейросети

Ради интереса проверил производительность - полный проход по колоде из 55 карточек (1485 комбинаций) занял 170 секунд, ошибок 0. Была идея сделать распознавание карточек по изображению с веб-камеры, но оказалось, что они сильно бликуют. Кроме того, у сети выявился недостаток - она сильно чувствительна к понижению разрешения картинки: мелкие детали (например, лучи снежинки) сливаются и это приводит к изменению параметров. Но сделать на ней игру с поиском совпадений по двум карточкам можно. Или использовать уже размеченные изображения символов для создания полноценного датасета и тренировки свёрточной нейронной сети.

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

Подробнее..

Категории

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

  • Имя: Макс
    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