habrahabr

Nexus 5 + JavaScript + 48 часов = сенсорная поверхность?

  • вторник, 11 ноября 2014 г. в 02:11:29
http://habrahabr.ru/post/242301/

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

Видео


Не дожидаясь вопроса в комментариях, где же видео, представляю вам видео с демонстрацией работы системы.

  • За два дня мы можем разработать сколь угодно бесполезную систему
  • и получить за нее приз в номинации «Самый эффектный хак»
  • Система работает на Nexus 5 в браузере Google Chrome. Я не тестировал ее на других устройствах и в других браузерах.
  • Наша разработка не дотягивает до оригинала, зато дешево. Сенсорный стол для бедных.


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