opencv4arts: Нарисуй мой город, Винсент +25


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


Право первой статьи получил эксперимент с переносом стилей мировых художников на фотографии. Из статьи вы узнаете, что является сердцем процедуры и об относительно новом OpenCV.js — JavaScript версии библиотеки OpenCV.



Style transfer


Да простят меня противники машинного обучения, но главной компонентой в сегодняшней статье будет глубокая сверточная сеть. Потому что работает. В OpenCV нет возможности тренировать нейронные сети, но можно запускать уже существующие модели. Мы будем использовать предобученную сеть CycleGAN. Авторы, за что им большая благодарность, предлагают совершенно свободно скачать сети, которые конвертируют изображения яблок в апельсины, лошадей в зебр, снимков со спутника в карты, фотографий зимы в фотографии лета и много чего ещё. Более того, процедура обучения сети позволяет иметь сразу две модели генератора, работающих в обе стороны. То есть, обучая преобразование зимы в лето вы получите и модель для рисования зимних пейзажей на летних фотографиях. Уникальное предложение, от которого невозможно отказаться.


В нашем примере мы возьмём модели, которые превращают фотографии в картины художников. А именно, Винсента Ван Гога, Клода Моне, Поля Сезанна или в целый жанр японских гравюр Ukiyo-e. То есть в нашем распоряжении будет четыре отдельные сети. Стоит заметить, что для обучения каждой использовалась не одна картина художника, а целое множество, тем самым авторы пытались обучить нейронную сеть не перекладывать стиль одного произведения, а, как бы, перенять стиль письма.


OpenCV.js


OpenCV — библиотека, разрабатываемая на языке C++, при этом для большей части ее функционала существует возможность создания автоматических оберток, которые вызывают нативные методы. Официально, поддерживаются обертки для языков Python и Java. Кроме того, существуют пользовательские решения для Go, PHP. Если у вас есть опыт использования в других языках — было бы здорово узнать, в каких, и благодаря чьим стараниям.


OpenCV.js — это проект, который получил право на жизнь благодаря программе Google Summer of Code в 2017 году. К слову, когда-то и сам deep learning модуль OpenCV был создан и значительно улучшался в его рамках. В отличие от других языков, OpenCV.js на данный момент — это не обертка нативных методов в JavaScript, а полноценная компиляция с помощью Emscripten, использующего LLVM и Clang. Он позволяет сделать из вашего C и C++ приложения или библиотеки .js файл, который можно запускать, скажем, в браузере.


Для примера,


#include <iostream>

int main(int argc, char** argv) {
  std::cout << "Hello, world!" << std::endl;
  return 0;
}

Компилируем в asm.js


emcc main.cpp -s WASM=0 -o main.js

И подгружаем:


<!DOCTYPE html>

<html>

<head>
  <script src="main.js" type="text/javascript"></script>
</head>

</html>

Подключить OpenCV.js к своему проекту можно следующим образом (ночная сборка):


<script src="https://docs.opencv.org/master/opencv.js" type="text/javascript"></script>

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


<script src="https://docs.opencv.org/master/utils.js" type="text/javascript"></script>

Загрузка изображений


Изображения в OpenCV.js могут быть прочитаны с элементов типа canvas или img. Это значит, что загрузка непосредственно файлов картинок на них остается задачей пользователя. Для удобства, вспомогательная функция addFileInputHandler, автоматически загрузит изображение в нужный элемент canvas при выборе картинки с диска по нажатию кнопки.


var utils = new Utils('');
utils.addFileInputHandler('fileInput', 'canvasInput');

var img = cv.imread('canvasInput');

где


<input type="file" id="fileInput" name="file" accept="image/*" />

<canvas id="canvasInput" ></canvas>

Важным моментом является то, что img будет 4-х канальным RGBA изображением, что отличается от привычного поведения cv::imread, который создает BGR картинку. Это нужно учитывать, например, при портировании алгоритмов с других языков.


С отрисовкой всё просто — достаточно одного вызова imshow с указанием id нужного canvas (ожидает RGB или RGBA).


cv.imshow("canvasOutput", img);

Алгоритм


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


Сеть, рассматриваемая в этом примере, принимает на вход четырехмерный тензор со значениями типа float в интервале [-1, 1]. Каждая из размерностей, в порядке скорости изменения — это индекс картинки, каналы, высота и ширина. Такую укладку принято называть NCHW, а сам тензор — блобом (blob, binary large object). Задача предобработки заключается в том, чтобы преобразовать изображение OpenCV, значения интенсивностей которого лежат вперемешку (interleaved), имеют интервал значений [0, 255] типа unsigned char в NCHW блоб с диапазоном значений [-1, 1].



кусочек нижегородского кремля (как видит человек)



interleaved представление (как хранит OpenCV)



planar представление (то, что нужно сети)


В качестве постобработки необходимо будет произвести обратные преобразования: сеть возвращает NCHW блоб со значениями в интервале [-1, 1], который нужно перепаковать в картинку, нормировать в [0, 255] и перевести в unsigned char.


Таким образом, с учётом всех особенностей чтения и записи картинок OpenCV.js, у нас вырисовываются следующие шаги:


imread -> RGBA -> BGR [0, 255] -> NCHW [-1, 1] -> [сеть]

[сеть] -> NCHW [-1, 1] -> RGB [0, 255] -> imshow

Глядя на полученный конвейер, возникают вопросы, почему сеть не может работать сразу на interleaved RGBA и возвращать interleaved RGB? Почему нужны лишние преобразования по перестановке пикселей и нормировке? Ответ в том, что нейронная сеть — это математический объект, который выполняет преобразования над входными данными определенного распределения. В нашем случае её обучили принимать данные именно в таком виде, поэтому для получения желаемых результатов, придется воспроизвести предобработку, которую использовали авторы при обучении.


Реализация


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


var net;
var url = 'style_vangogh.t7';
utils.createFileFromUrl('style_vangogh.t7', url, () => {
  net = cv.readNet('style_vangogh.t7');
});

Кстати, url — это полноценная ссылка на файл. В данном случае мы просто подгружаем файл, лежащий рядом с текущей HTML страницей, но вы можете заменить её на оригинальный источник (в таком случае время скачивания может быть больше).


Чтение изображения с canvas и конвертация из RGBA в BGR:


var imgRGBA = cv.imread('canvasInput');
var imgBGR = new cv.Mat(imgRGBA.rows, imgRGBA.cols, cv.CV_8UC3);
cv.cvtColor(imgRGBA, imgBGR, cv.COLOR_RGBA2BGR);

Создание 4D блоба, где функция blobFromImage выполняет конвертацию в тип данных float, применяя нормировочные константы. Затем — запуск сети.


var blob = cv.blobFromImage(imgBGR, 1.0 / 127.5,  // множитель
                            {width: imgBGR.cols, height: imgBGR.rows},  // размеры
                            [127.5, 127.5, 127.5, 0]);  // вычитание среднего
net.setInput(blob);
var out = net.forward();

Полученный результат преобразуется обратно в картинку нужного типа и интервалом значений [0, 255]


// Нормировка значений из интервала [-1, 1] в [0, 255]
var outNorm = new cv.Mat();
out.convertTo(outNorm, cv.CV_8U, 127.5, 127.5);

// Создание interleaved изображения из planar блоба
var outHeight = out.matSize[2];
var outWidth = out.matSize[3];
var planeSize = outHeight * outWidth;

var data = outNorm.data;
var b = cv.matFromArray(outHeight, outWidth, cv.CV_8UC1, data.slice(0, planeSize));
var g = cv.matFromArray(outHeight, outWidth, cv.CV_8UC1, data.slice(planeSize, 2 * planeSize));
var r = cv.matFromArray(outHeight, outWidth, cv.CV_8UC1, data.slice(2 * planeSize, 3 * planeSize));

var vec = new cv.MatVector();
vec.push_back(r);
vec.push_back(g);
vec.push_back(b);
var rgb = new cv.Mat();
cv.merge(vec, rgb);

// Отрисовка результата
cv.imshow("canvasOutput", rgb);

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


dnn = {'dnn_Net': ['setInput', 'forward'],
       '': ['readNetFromCaffe', 'readNetFromTensorflow',
            'readNetFromTorch', 'readNetFromDarknet',
            'readNetFromONNX', 'readNet', 'blobFromImage']}

Последнее преобразование, разделяющее блоб на три канала и затем перемешивающее их в картинку, на самом деле, можно выполнить одним методом imagesFromBlob, которое просто ещё не добавили в список выше. Возможно, это будет твоим первым вкладом в развитие OpenCV? ;)


Заключение


В качестве демонстрации, подготовил страничку на GitHub, где вы можете протестировать результирующий код: https://dkurtaev.github.io/opencv4arts (Осторожно! Скачивание сети около 22MB, берегите свой трафик. Также рекомендуется перезагружать страницу для каждого нового изображения, иначе качество последующих обработок как-то сильно искажается). Будьте готовы к долгому процессу обработки или попробуйте поменять размеры картинки, которая будет в результате, слайдером.


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


От меня — забавный факт. Большинство жителей Нижнего Новгорода и Нижегородской области употребляют слово “убраться” в смысле слова “поместиться” (найти себе свободное место). Например, вопрос “Мы уберемся в вашей машине?” означает “Хватит ли нам места в вашей машине?”, а не “Можно ли нам навести порядок в вашей машине?”. Когда к нам на летние стажировки приезжают студенты из других областей, любим рассказывать этот факт — многие искренне удивляются.


Полезные ссылки





К сожалению, не доступен сервер mySQL