Несколько недель назад в Минске проходил хакатон WTH.BY, в котором я решил принять участие. Его основной идеей было то, что это хакатон для разработчиков. Мы могли делать все, что угодно, лишь бы нам это было весело и интересно. Никаких монетизаций, инвестиций и менторов. Всё весело и круто!
Идей для реализации у меня было много, но все они не дотягивали до какого-то «Вау!». Именно поэтому накануне мероприятия я пролистывал старые статьи хабра из раздела DIY и наткнулся на статью "Опыт создания multitouch стола". Это было то, что вызвало тот самый отсутствующий «Вау!» и я решил сделать отдаленный аналог из того, чтобы под рукой.
Под рукой у меня оказалось стекло формата примерно А3, обычная бумага, маркер, мобильный телефон и ноутбук. Я быстро нашел себе сообщника Егора и началась активная работа.
В общем, было решено сделать сенсорную поверхность, касания на которой распознавались бы нашей системой. Для этого я стащил из дома кусок обычного стекла, бумагу и маркер. Стекло мы положили на две стопки книг, на него приклеили скотчем лист бумаги, а снизу положили телефон фронтальной камерой вверх. Камера снимает изображение снизу, распознает изображение места прикосновения и передает их на ноутбук. Уже по ходу дела идея немного трансформировалась: распознавать нарисованные маркером на бумаге кнопки и определять нажатия на них. В первую очередь это случилось из-за того, что распознать точное место прикосновения проблематично из-за тени руки. А нарисованные маркером кнопки видны отчетливо и выделить их на изображении было легко.
Учитывая, что мой профиль в программировании — JavaScript, мы решили, что это будет веб-страница, которая открывается на телефоне. На ней захватывается видео изображение с фронтальной камеры, распознаются кнопки и ожидаются нажатия. При возникновении события информация передается с помощью сокетов на другую страницу на ноутбуке, которая делает, что ей понравится прикажут.
Такую систему можно разбить на несколько логичных частей:
- Захват видео
- Предварительная обработка изображения
- Поиск контуров
- Определение нахождение пальца в контуре
- Передача событий клиентской странице
Рассмотрим каждую часть немного подробнее.
Захват видео
Уверен, что для вас не будет секретом, что используя метод getUserMedia можно получить изображение с видеокамеры и транслировать его в теге video. Поэтому создаем тег video, просим у пользователя разрешение на захват видео и видим себя в камеру.
var video = (function() {
var video = document.createElement("video");
video.setAttribute("width", options.width.toString());
video.setAttribute("height", options.height.toString());
video.className = (!options.showVideo) ? "hidden" : "";
video.setAttribute("loop", "");
video.setAttribute("muted", "");
container.appendChild(video);
return video
})(),
initVideo = function() {
// initialize web camera or upload video
video.addEventListener('loadeddata', startLoop);
window.navigator.webkitGetUserMedia({video: true}, function(stream) {
try {
video.src = window.URL.createObjectURL(stream);
} catch (error) {
video.src = stream;
}
setTimeout(function() {
video.play();
}, 500);
}, function (error) {});
};
//...
initVideo();
Чтобы получить отдельный кадр из видео, будем использовать canvas и метод drawImage. Этот метод может принимать первым параметром тег видео и рисовать в canvas текущий кадр из указанного видео. Это как раз то, что нам нужно. Эту операцию мы будем повторять через определенные интервалы времени.
var captureFrame = function() {
ctx.drawImage(video, 0, 0, options.width, options.height);
return ctx.getImageData(0, 0, options.width, options.height);
};
window.setInterval(function() {
captureFrame();
}, 50);
Предварительная обработка изображения
Теперь у нас есть элемент canvas, а в нем текущий кадр из видеопотока. Следующая задача — распознавание нарисованных кнопок.
На самом деле вид, в котором возвращает данные метод ctx.getImageData(...), совсем неудобный для решения поставленной задачи. Поэтому прежде, чем приступить к непосредственному поиску контуров, приведем изображение к удобному формату.
Метод getImageData возвращает большой массив данных, где последовательно описаны каналы каждого пикселя. А под удобным форматом я понимаю двумерный массив пикселей. Он интуитивно понятен и работать с ним гораздо приятнее.
Напишем небольшую функцию, которая преобразует данные в удобный для нас вид. При этом можно учитывать, что изображение, проходящее сквозь бумагу, очень похоже на черно-белое. Поэтому для каждого пикселя мы посчитаем среднюю сумму каналов и запишем ее в результирующий массив. В результате получаем массив, где каждый пиксель представлен значением от 0 до 255. По координатам можно обратиться к нужному пикселю и получить его значение: data[y][x].
Мы пошли еще дальше и решили, что для каждого пикселя 255 возможных значений — это слишком много. Для распознавания контуров и нажатий достаточно двух значений — 1 и 0. Так в нашем проекте появилась функция getContours, которая получала на вход массив пикселей и переменную limit. Если значение конкретного пикселя больше переменной limit, то он превращается в ноль (светлый лист), в противном случае становился единицей (часть контура или пальца).
var getContours = function(matrix, limit) {
var x, y;
for (y = 0; y < options.height; y++) {
for (x = 0; x < options.width; x++) {
matrix[y][x] = (matrix[y][x] > limit) ? 0 : 1;
}
}
return matrix;
};
Теперь изображение представлено в удобном виде и готово к тому, чтобы мы нашли на нем кнопки.
Поиск контуров
Вы когда-нибудь распознавали контуры и предметы на изображении? Я раньше никогда такого не делал. Быстрое гугление показало, что OpenCV должен решать эти задачи без особых проблем. На деле же оказалось, что портированные библиотеки имеют какие-то ограничения, а классификаторы нужно обучать. Все это было похоже на использование Grails для создания landing page.
Именно поэтому мы продолжили поиски более простых решений и наткнулись на алгоритм жука (не уверен, что это общепринятое название, но в статье он назывался именно так).
Алгоритм позволяет в массиве нулей и единиц распознать замкнутые контуры. Важным требованием стало то, что границы должны быть толщиной не менее двух пикселей. Иначе логика алгоритма попадала в бесконечный цикл со всеми вытекающими последствиями. Но граница кнопки, нарисованная маркером было гораздо толще двух пикселей, поэтому это не стало для нас проблемой. В остальном алгоритм очень простой:
- Находим граничную точку. Граничная точка — это переход с белой точки на черную. Можно просто пройтись по массиву и найти первую попавшуюся.
- Начинаем обход контура по двум простым правилам:
- Если мы находимся на белой точке, то поворачиваем направо
- Если мы находимся на черной точке, то поворачиваем налево
При движении по точкам не забываем записывать координаты черных точек, на которых мы находимся, в результирующий массив. Впоследствии этот массив и будет контуром.
- Завершаем обход контура в граничной точке, с которой начали.
Итак, у нас есть функция, которая получает на вход данные и находит контур. Для упрощения задачи ограничились только прямоугольными формами. Поэтому по точкам контура мы находим две ограничивающие точки. Независимо от формы кнопки мы получаем прямоугольник, в который она вписана.
Но кому нужен интерфейс из одной кнопки? Если уж делать, то по полной! Так и возникла задача поиска всех нарисованных кнопок. Решение оказалось простым: находим кнопку, запоминаем ее в массив, прямоугольник с кнопкой в данных заливаем нулями. Повторяем поиск до тех пор, пока массив не станет пустым. В результате получаем массив, содержащий все найденные кнопки.
Кстати, в процессе тестирования алгоритма пострадало одно стекло. К счастью был поздний вечер и я собирался домой. Утром я достал из окна раздобыл дома еще одно стекло и отправился продолжать разработку.
Определение нахождение пальца в контуре
Как же быть с нажатием кнопок? Тут все оказалось просто. При нахождении кнопки посчитаем сумму черных точек внутри нее. Я для себя эту величину называл «хэш кнопки». Так вот если на кнопку нажали, то хэш кнопки вырастает на ощутимое количество, которое явно превышает случайные шумы, помехи и минимальные движения бумаги и телефона относительно друг друга. Получается, что в каждом фрейме нужно считать хэш существующей кнопки и сравнивать его с исходным значением:
- Если разница между значениями больше заданного значения, то считаем, что кнопка нажата и вызываем событие touchstart.
- Если же до этого кнопка была нажата, а теперь сумма вернулась в норму, то считаем, что нажатие прекратилось и случилось событие touchend.
Такой вот тач-скрин.
Ну в общем-то да. С этим можно пробовать бороться, устанавливая дополнительные проверки. Например можно создавать второй массив данных из нулей и единиц, но с более строгим лимитом черного цвета. Тогда только «наиболее черный» цвет останется на изображении. Это даст возможность предполагать, что в данных останется только место прикосновения пальцем к бумаге, отсеивая тень.
Ну или можно воспользоваться правилами хакатона «делай, что хочешь» и сказать, что так задумано.
Передача событий клиентской странице
Уверен, что все знают, что такое Socket.io. А если еще не знаете, то можете почитать у них на сайте http://socket.io/. Если вкратце, то это библиотека, дающая возможность обмениваться данными между сервером node.js и клиентом в двухстороннем порядке. В нашем случае мы используем их, чтобы переслать информацию о событиях другой веб-странице через сервер с минимальной задержкой.
Видео
Не дожидаясь вопроса в комментариях, где же видео, представляю вам видео с демонстрацией работы системы.
Выводы
- За два дня мы можем разработать сколь угодно бесполезную систему
- и получить за нее приз в номинации «Самый эффектный хак»
- Система работает на Nexus 5 в браузере Google Chrome. Я не тестировал ее на других устройствах и в других браузерах.
- Наша разработка не дотягивает до оригинала, зато дешево. Сенсорный стол для бедных.
Полезные ссылки
- Проект TouchPaper на GitHub
- Захват видеоизображения с камеры на html5rocks.com
- Алгоритм жука
- Библиотека Socket.io
Автор: radyno