Автор статьи, перевод которой мы публикуем, предлагает поговорить о решении задач из сферы компьютерного зрения исключительно средствами веб-браузера. Решить подобную задачу не так уж и трудно благодаря 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.
Добавим в HTML-файл следующий код. Он должен располагаться в пределах тега <body>
, но выше тегов <script>
:
<video id="webcam" width="400" height="300" autoplay></video>
Теперь поработаем с файлом main.js
:
$(document).ready(function() {
const video = $('#webcam')[0];
function onStreaming(stream) {
video.srcObject = stream;
}
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>
. То, что элементы будут расположены в одной и той же позиции, обеспечивают использованные здесь стили.
Теперь, каждый раз, когда браузер выводит очередной кадр видео, мы собираемся рисовать что-то на элементе <canvas>
. Выполнение какого-либо кода при выводе каждого кадра выполняется с помощью механизма requestAnimationLoop()
. Прежде чем мы выведем что-либо в элемент <canvas>
, нам нужно удалить с него то, что было на нём раньше, очистив его. Затем мы можем предложить clmtrackr
выполнять вывод графики прямо на элемент <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 даёт нам не только сведения о расположении лица, но и 70 контрольных точек. Если взглянуть на документацию к cmltracker, можно выбрать именно те контрольные точки, которые нам нужны.
Контрольные точки
Решим, что глаза — это прямоугольная часть изображения, границы которой касаются точек 23, 28, 24 и 26, расширенная на 5 пикселей в каждом направлении. Этот прямоугольник должен включать в себя всё, что для нас важно, если только пользователь не слишком сильно наклоняет голову.
Теперь, прежде чем мы сможем воспользоваться этим фрагментом изображения, нам нужен ещё один элемент <canvas>
для его вывода. Его размеры будут равны 50x25 пикселей. Прямоугольник с глазами будет вписан в этот элемент. Небольшие деформации изображения — это не проблема.
Добавьте в 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?
Автор: ru_vds