Главная » Хабрахабр » [Перевод] TensorFlow.js и clmtrackr.js: отслеживание направления взгляда пользователя в браузере

[Перевод] TensorFlow.js и clmtrackr.js: отслеживание направления взгляда пользователя в браузере

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

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

Идея

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

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

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

Входные данные с веб-камеры, распознавание лица, обнаружение глаз, обрезанное изображение

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

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

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

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

Подготовка

Для начала загрузим clmtrackr.js из соответствующего репозитория. Работу начнём с пустого HTML-файла, в котором импортируются jQuery, TensorFlow.js, clmtrackr.js и файл main.js с нашим кодом, над которым мы будем работать немного позже:

<!doctype html>
<html>
<body> <script src="https://code.jquery.com/jquery-3.3.1.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@0.12.0"></script> <script src="clmtrackr.js"></script> <script src="main.js"></script>
</body>
</html>

Получение видеопотока с веб-камеры

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

Он должен располагаться в пределах тега <body>, но выше тегов <script>: Добавим в HTML-файл следующий код.

<video id="webcam" width="400" height="300" autoplay></video>

Теперь поработаем с файлом main.js:

$(document).ready(function() navigator.mediaDevices.getUserMedia({ video: true }).then(onStreaming);
});

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

Позже мы расширим код функции onStreaming().

Поиск лица

Теперь давайте воспользуемся библиотекой clmtrackr.js для поиска лица на видео. Для начала инициализируем систему слежения за лицом, добавив следующий код после const video = ...:

const ctrack = new clm.tracker();
ctrack.init();

Теперь, в функции onStreaming(), мы подключаем систему поиска лица, добавляя туда следующую команду:

ctrack.start(video);

Это всё, что нам нужно. Теперь система сможет распознать лицо в видеопотоке.

Давайте, для того, чтобы вы в этом убедились, нарисуем вокруг лица «маску».
Для того чтобы это сделать, нам нужно вывести изображение поверх элемента, ответственного за показ видео. Не верите? Поэтому создадим такой элемент, наложив его на элемент, выводящий видео. Рисовать что-либо на HTML-страницах можно с помощью тега <canvas>. В этом нам поможет следующий код, который надо добавить в HTML-файл под уже имеющимся там элементом <video>:

<canvas id="overlay" width="400" height="300"></canvas>
<style> #webcam, #overlay { position: absolute; top: 0; left: 0; }
</style>

Если хотите — можете переместить встроенный стиль в отдельный CSS-файл.

То, что элементы будут расположены в одной и той же позиции, обеспечивают использованные здесь стили. Тут мы добавили на страницу элемент <canvas> того же размера, что и элемент <video>.

Выполнение какого-либо кода при выводе каждого кадра выполняется с помощью механизма requestAnimationLoop(). Теперь, каждый раз, когда браузер выводит очередной кадр видео, мы собираемся рисовать что-то на элементе <canvas>. Затем мы можем предложить clmtrackr выполнять вывод графики прямо на элемент <canvas>. Прежде чем мы выведем что-либо в элемент <canvas>, нам нужно удалить с него то, что было на нём раньше, очистив его.

Добавить его надо ниже команды ctrack.init(): Вот код, реализующий то, о чём мы только что говорили.

const overlay = $('#overlay')[0];
const overlayCC = overlay.getContext('2d'); function trackingLoop() { // Проверим, обнаружено ли в видеопотоке лицо, // и если это так - начнём его отслеживать. requestAnimationFrame(trackingLoop); let currentPosition = ctrack.getCurrentPosition(); overlayCC.clearRect(0, 0, 400, 300); if (currentPosition) { ctrack.draw(overlay); }
}

Теперь вызовем функцию trackingLoop() в функции onStreaming() сразу после ctrack.start(). Эта функция будет сама планировать собственный перезапуск в каждом кадре.

Вы должны увидеть зелёную «маску» вокруг лица в окне видео. Обновите страницу и посмотрите в веб-камеру. Иногда для того чтобы система правильно распознала лицо, нужно немного подвигать головой в кадре.

Результаты распознавания лица

Выявление области изображения, содержащей глаза

Теперь нам нужно обнаружить прямоугольную область изображения, в которой находятся глаза, и поместить её на отдельный элемент <canvas>.

Если взглянуть на документацию к cmltracker, можно выбрать именно те контрольные точки, которые нам нужны. К счастью, cmltracker даёт нам не только сведения о расположении лица, но и 70 контрольных точек.

Контрольные точки

Этот прямоугольник должен включать в себя всё, что для нас важно, если только пользователь не слишком сильно наклоняет голову. Решим, что глаза — это прямоугольная часть изображения, границы которой касаются точек 23, 28, 24 и 26, расширенная на 5 пикселей в каждом направлении.

Его размеры будут равны 50x25 пикселей. Теперь, прежде чем мы сможем воспользоваться этим фрагментом изображения, нам нужен ещё один элемент <canvas> для его вывода. Небольшие деформации изображения — это не проблема. Прямоугольник с глазами будет вписан в этот элемент.

Добавьте в HTML-файл этот код, описывающий элемент <canvas>, в который попадёт та часть изображения, на которой имеются глаза:

<canvas id="eyes" width="50" height="25"></canvas>
<style> #eyes { position: absolute; top: 0; right: 0; }
</style>

Следующая функция вернёт координаты x и y, а также ширину и высоту прямоугольника, окружающего глаза. Она, в качестве входных данных, принимает массив positions, полученный от clmtrackr. Обратите внимание на то, что каждая координата, полученная от clmtrackr, имеет компоненты x и y. Эту функцию надо добавить в main.js:

function getEyesRectangle(positions) { const minX = positions[23][0] - 5; const maxX = positions[28][0] + 5; const minY = positions[24][1] - 5; const maxY = positions[26][1] + 5; const width = maxX - minX; const height = maxY - minY; return [minX, minY, width, height];
}

Теперь, в каждом кадре, мы собираемся извлекать из видеопотока прямоугольник с глазами, обводить его красной линией на элементе <canvas>, который наложен на элемент <video>, а затем копировать его в новый элемент <canvas>. Обратите внимание на то, что для того, чтобы правильно выявить нужную нам область, мы будем рассчитывать показатели resizeFactorX и resizeFactorY.

Замените следующим кодом блок if в функции trackingLoop():

if (currentPosition) { // Выведем линии, проведённые между контрольными точками // на элементе <canvas>, наложенном на элемент <video> ctrack.draw(overlay); // Получим прямоугольник, ограничивающий глаза, и обведём его // красными линиями const eyesRect = getEyesRectangle(currentPosition); overlayCC.strokeStyle = 'red'; overlayCC.strokeRect(eyesRect[0], eyesRect[1], eyesRect[2], eyesRect[3]); // Видеопоток может иметь особые внутренние параметры, // поэтому нам нужны эти константы для перемасштабирования // прямоугольника с глазами перед обрезкой const resizeFactorX = video.videoWidth / video.width; const resizeFactorY = video.videoHeight / video.height; // Вырезаем прямоугольник с глазами из видео и выводим его // в соответствующем элементе <canvas> const eyesCanvas = $('#eyes')[0]; const eyesCC = eyesCanvas.getContext('2d'); eyesCC.drawImage( video, eyesRect[0] * resizeFactorX, eyesRect[1] * resizeFactorY, eyesRect[2] * resizeFactorX, eyesRect[3] * resizeFactorY, 0, 0, eyesCanvas.width, eyesCanvas.height );
}

Перезагрузив теперь страницу, вы должны увидеть красный прямоугольник вокруг глаз, а то, что содержит этот прямоугольник — в соответствующем элементе <canvas>. Если ваши глаза больше моих — поэкспериментируйте с функцией getEyeRectangle.

Элемент <canvas>, выводящий прямоугольник, содержащий изображение глаз пользователя

Сбор данных

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

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

▍Отслеживание перемещений мыши

Для того чтобы узнать, где именно на веб-странице расположен указатель мыши, нам понадобится обработчик события document.onmousemove. Наша функция, кроме того, нормализует координаты таким образом, чтобы они укладывались в диапазон [-1, 1]:

// Отслеживание перемещений мыши:
const mouse = { x: 0, y: 0, handleMouseMove: function(event) { // Получим позицию указателя и нормализуем её, приведя к диапазону [-1, 1] mouse.x = (event.clientX / $(window).width()) * 2 - 1; mouse.y = (event.clientY / $(window).height()) * 2 - 1; },
} document.onmousemove = mouse.handleMouseMove;

▍Захват изображений

Для захвата изображения, выводимого элементом <canvas> и сохранения его в виде тензора, TensorFlow.js предлагает вспомогательную функцию tf.fromPixels(). Используем её для сохранения и последующей нормализации изображения с элемента <canvas>, выводящего прямоугольник, содержащий глаза пользователя:

function getImage() { // Захват текущего изображения в виде тензора return tf.tidy(function() { const image = tf.fromPixels($('#eyes')[0]); // Добавление <i><font color="#999999">измерения</font></i>: const batchedImage = image.expandDims(0); // Нормализация и возврат данных: return batchedImage.toFloat().div(tf.scalar(127)).sub(tf.scalar(1)); });
}

Обратите внимание на то, что функция tf.tidy() используется для того, чтобы навести порядок после завершения работы.

Именно поэтому нам надо сохранить некоторые образцы в отдельной контрольной выборке. Мы могли бы просто сохранить все образцы в одной большой обучающей выборке, однако в машинном обучении важно проверять качество обучения модели. Для этой цели 20% от общего количества образцов включены в контрольную выборку. После этого мы можем проверить поведение модели на новых для неё данных и узнать, не произошло ли чрезмерного обучения модели.

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

const dataset = { train: { n: 0, x: null, y: null, }, val: { n: 0, x: null, y: null, },
} function captureExample() { // Возьмём самое свежее изображение глаз и добавим его в набор данных tf.tidy(function() { const image = getImage(); const mousePos = tf.tensor1d([mouse.x, mouse.y]).expandDims(0); // Решим, в какую выборку (обучающую или контрольную) его добавлять const subset = dataset[Math.random() > 0.2 ? 'train' : 'val']; if (subset.x == null) { // Создадим новые тензоры subset.x = tf.keep(image); subset.y = tf.keep(mousePos); } else { // Конкатенируем их с существующими тензорами const oldX = subset.x; const oldY = subset.y; subset.x = tf.keep(oldX.concat(image, 0)); subset.y = tf.keep(oldY.concat(mousePos, 0)); } // Увеличим счётчик subset.n += 1; });
}

И, наконец, нам надо привязать эту функцию к клавише Пробел:

$('body').keyup(function(event) { // Выполняется при нажатии на клавишу Пробел на клавиатуре if (event.keyCode == 32) { captureExample(); event.preventDefault(); return false; }
});

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

Обучение модели

Создадим простую свёрточную нейронную сеть. TensorFlow.js предоставляет для этой цели API, напоминающее Keras. У сети должен быть слой conv2d, слой maxPooling2d, и, наконец, слой dense c двумя выходными значениями (они представляют экранные координаты). Попутно я добавил в сеть, в качестве регуляризатора, слой dropout, и слой flatten для того, чтобы преобразовать двухмерные данные в одномерные. Обучение сети выполняется с помощью оптимизатора Adam.

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

Вот код модели:

let currentModel; function createModel() { const model = tf.sequential(); model.add(tf.layers.conv2d({ kernelSize: 5, filters: 20, strides: 1, activation: 'relu', inputShape: [$('#eyes').height(), $('#eyes').width(), 3], })); model.add(tf.layers.maxPooling2d({ poolSize: [2, 2], strides: [2, 2], })); model.add(tf.layers.flatten()); model.add(tf.layers.dropout(0.2)); // Два выходных значения x и y model.add(tf.layers.dense({ units: 2, activation: 'tanh', })); // Используем оптимизатор Adam с коэффициентом скорости обучения 0.0005 и с функцией потерь MSE model.compile({ optimizer: tf.train.adam(0.0005), loss: 'meanSquaredError', }); return model;
}

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

function fitModel() { let batchSize = Math.floor(dataset.train.n * 0.1); if (batchSize < 4) { batchSize = 4; } else if (batchSize > 64) { batchSize = 64; } if (currentModel == null) { currentModel = createModel(); } currentModel.fit(dataset.train.x, dataset.train.y, { batchSize: batchSize, epochs: 20, shuffle: true, validationData: [dataset.val.x, dataset.val.y], });
}

Теперь добавим на страницу кнопку для запуска обучения. Этот код идёт в HTML-файл:

<button id="train">Train!</button>
<style> #train { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 24pt; }
</style>

Этот код надо добавить в JS-файл:

$('#train').click(function() { fitModel();
});

Куда смотрит пользователь?

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

Сначала добавим на страницу кружок:

<div id="target"></div>
<style> #target { background-color: lightgreen; position: absolute; border-radius: 50%; height: 40px; width: 40px; transition: all 0.1s ease; box-shadow: 0 0 20px 10px white; border: 4px solid rgba(0,0,0,0.5); }
</style>

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

function moveTarget() { if (currentModel == null) { return; } tf.tidy(function() { const image = getImage(); const prediction = currentModel.predict(image); // Конвертируем нормализованные координаты в позицию на экране const targetWidth = $('#target').outerWidth(); const targetHeight = $('#target').outerHeight(); const x = (prediction.get(0, 0) + 1) / 2 * ($(window).width() - targetWidth); const y = (prediction.get(0, 1) + 1) / 2 * ($(window).height() - targetHeight); // Переместим в нужное место кружок: const $target = $('#target'); $target.css('left', x + 'px'); $target.css('top', y + 'px'); });
} setInterval(moveTarget, 100);

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

Итоги

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

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

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

  • Возможности по настройке прямоугольника, ограничивающего глаза, описанные выше.
  • Преобразование изображения в оттенки серого.
  • Использование CoordConv.
  • Тепловая карта для проверки того, где модель показала себя хорошо, а где — нет.
  • Возможность сохранять и загружать наборы данных.
  • Возможность сохранять и загружать модели.
  • Сохранение весов, показавших после обучения минимальные потери при проверке.
  • Улучшенный пользовательский интерфейс с краткой инструкцией по работе с системой.

Уважаемые читатели! Пользуетесь ли вы TensorFlow?


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

9 кругов автоматизации склада Lamoda

Наш склад размером с две Красные площади и высотой в 5 этажей работает круглый год и никогда не спит — 24/7 364 дня в году (единственный выходной — 1 января). У нас хранится и обслуживается более 8 000 000 товаров, ...

[Перевод] Каскадные SFU: улучшаем масштабируемость и качество медиа в WebRTC-приложениях

В развертывании медиасерверов для WebRTC есть две сложности: масштабирование, т.е. выход за рамки использования одного сервера и оптимизация задержек для всех пользователей конференции. В то время как простой шардинг в духе «отправить всех юзеров конференции X на сервер Y» легко ...