Nexus 5 + JavaScript + 48 часов = сенсорная поверхность?
Несколько недель назад в Минске проходил хакатон 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, то он превращается в ноль (светлый лист), в противном случае становился единицей (часть контура или пальца).
Код функции getContours 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 и клиентом в двухстороннем порядке. В нашем случае мы используем их, чтобы переслать информацию о событиях другой веб-странице через сервер с минимальной задержкой.Видео Не дожидаясь вопроса в комментариях, где же видео, представляю вам видео с демонстрацией работы системы.[embedded content]
Выводы За два дня мы можем разработать сколь угодно бесполезную систему и получить за нее приз в номинации «Самый эффектный хак» Система работает на Nexus 5 в браузере Google Chrome. Я не тестировал ее на других устройствах и в других браузерах. Наша разработка не дотягивает до оригинала, зато дешево. Сенсорный стол для бедных. Полезные ссылки