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, берегите свой трафик. Также рекомендуется перезагружать страницу для каждого но��ого изображения, иначе качество последующих обработок как-то сильно искажается). Будьте готовы к долгому процессу обработки или попробуйте поменять размеры картинки, которая будет в результате, слайдером.


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


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


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