Наверное, многие из вас видели интерактивные игры для детей в торговых центрах. Где динамическая сцена проецируется на пол, а рядом установленный сенсор определяет точки касания с поверхностью и преобразует их в события для приложения на управляющем компьютере. После поиска в интернете информации об этом устройстве оказалось, что это довольно дорогая игрушка. Например, китайские клоны стартуют с ценника в $1200, а что-то более оригинальное стоит уже $10 тыс. После анализа технической составляющей продукта было решено сделать аналогичное устройство самому.

Железо проекта состоит из трех частей:
  • Сенсор глубины (в оригинале это ASUS Xtion);
  • Управляющий компьютер (Cubieboard A80, ODROID-U3);
  • Проектор.




В идеале все железки вместе не должны стоить больше 700 долларов. Предполагалось, что соединить все три части должно быть относительно легко, так как в интернете есть такие библиотеки, как OpenNI и libfreenect, которые работают и на Android, и на Linux. Из-за недостатка опыта на раннем этапе казалось, что есть выбор и в железе, и в ОС; есть примеры открытого кода и соединить все вместе не составит большого труда. Через некоторое время после начала проекта оказалось, что это не так. Интеграция всех частей и даже запуск библиотек на целевом устройстве есть самая сложная задача. Пришлось выбирать между доступностью информации по настройке Linux и обилием приложений в маркете под платформу Android.

Однако, обо всем по порядку.

Для того, что бы сразу начать экспериментировать с железом, был куплен б/у сенсор Microsoft Kinect и проектор. Затем из квадратной профилированной трубы изготовлено вот такое крепление для проектора и сенсора:



В верхней части крепления приварен небольшой кусок уголка для монтажа к потолку. В местах изгибов трубы для усиления конструкции приварены пластинки в виде косынок. Проектор соединяется с креплением через треугольную пластину из фанеры. Для соединения сенсора с креплением используется специальный аксессуар для Kinect, который можно без проблем найти на ebay. В качестве управляющего компьютера для удешевления была выбрана плата Cubieboard A10, которую также без проблем можно найти на ebay. На момент написания статьи уже вышли Cubieboard A20 и A80, соответственно двух и восьми-ядерные аналоги. Если позволяет бюджет, то желательно купить A80, чтобы у системы был запас мощности для одновременной работы пользовательских приложений и сервиса по захвату и обработке данных от сенсора глубины. За питание платы и сенсора отвечает USB блок питания с выходным током на 4A. Проектор и сенсор соединяются с креплением так, что бы камера глубины была в одной плоскости с объективом проектора:



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

В качестве операционной системы была выбрана сборка Android под Cubieboard с забавной заставкой на рабочем столе. Мне пришлось немного подправить файлы конфигурации и скомпилировать сборку самому из-за того, что в Android нельзя изменить последовательность загрузки модулей, точнее можно, но до следующей перезагрузки системы.

Для внедрения событий потребовался модуль драйвера сенсорного экрана sun4i-ts. На самом деле, тестовое приложение реализует TUIO клиент, но, как оказалось, даже с драйвером сенсорной панели существующий под Android сервер TUIO не поддерживает multitouch события. Возможно, это связано с самим драйвером сенсорной панели sun4i-ts под Allwinner. Исходя из этих фактов был выбран вариант с прямым внедрением событий.

Для захвата данных о глубине используется легковесная и быстрая библиотека libfreenect, которая, в свою очередь, использует libusb для передачи данных по USB. Полученные данные о глубине обрабатываются с помощью OpenCV для Android. Суть обработки довольно простая: карту глубины необходимо преобразовать в замкнутые контуры с длиной не меньше, чем пороговая, для исключения ложных срабатываний — и найти их геометрические центры.

В самом начале работы, когда на сцене нет ни одного объекта, приложение строит карту глубины фона, затем в процессе работы карта используется для отделения целевых объектов от фона. Приложение представляет собой управляющую часть и сервис с кодом на С/C++. Вся логика обработки и захвата данных о глубине реализована на С/C++. Часть кода по работе с TUIO и OpenCV была взята из этого проекта на github.

Рассмотрим код более подробно. В коде, как я уже говорил, используется OpenCV. В самом начале работы приложение строит карту глубины:

1   void STouchDetector::process(const uint16_t& depthData) {
2   	frmCount++;
3   	// create background model (average depth)
4   	if (frmCount < BackgroundTrain) {
5       	depth.data = (uchar*)(&depthData);
6       	buffer[frmCount] = depth;
7   	}
8   	else {
9       	if (frmCount == BackgroundTrain) {
10          	// Calculate average depth based on all frames from buffer
11          	average(buffer, background);
12          	Scalar bmeanVal = mean(background(roi));
13          	double bminVal = 0.0, bmaxVal = 0.0;
14          	minMaxLoc(background(roi), &bminVal, &bmaxVal);
15          	LOGD("Background extraction completed. Average depth is %f min %f max %f", bmeanVal.val[0], bminVal, bmaxVal);
16      	}

В строке 6 данные о глубине сохраняются в буфере. Следует отметить, что буфер имеет тип std::vector<cv::Mat1s>. Это массив из матриц и присваивание в строке 6 — это фактически копирование всех пикселей кадра в буфер. После достижения счетчиком кадров порогового значения BackgroundTrain вызывается функция подсчета среднего значения глубины по всем кадрам в строке 11:

1   void STouchDetector::average(vector<Mat1s>& frames, Mat1s& mean) {
2   	Mat1d acc(mean.size());
3   	Mat1d frame(mean.size());
4   	for (unsigned int i=0; i<frames.size(); i++) {
5       	frames[i].convertTo(frame, CV_64FC1);
6       	acc = acc + frame;
7   	}
8   	acc = acc / frames.size();
9   	acc.convertTo(mean, CV_16SC1);
10  }

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

В следующей части кода выделяются объекты с помощью ранее созданного фона глубины в строке 4. Затем с помощью функции OpenCV findContours() выделяются контуры. Для контуров с длиной больше пороговой подсчитывается их геометрический центр. Координаты полученных центров добавляются в массив touchPoints, который хранит координаты событий нажатия на поверхность:

1   	// Update 16 bit depth matrix
2   	depth.data = (uchar*)(&depthData);
3   	// Extract foreground by simple subtraction of very basic background model
4   	foreground = background - depth;
5
6   	// Find touch mask by thresholding (points that are close to background = touch points)
7   	touch = (foreground > TouchDepthMin) & (foreground < TouchDepthMax);
8
9   	// Extract ROI
10  	Mat touchRoi = touch(roi);
11
12  	// Find contours by depth data
13  	vector< vector<Point2i> > contours;
14  	vector<Point2f> touchPoints;
15  	findContours(touchRoi, contours, CV_RETR_LIST, CV_CHAIN_APPROX_SIMPLE, Point2i(xMin, yMin));
16
17  	for (unsigned int i=0; i < contours.size(); i++) {
18      	Mat contourMat(contours[i]);
19      	// Find touch points by area thresholding
20        	if ( contourArea(contourMat) > ContourAreaThreshold ) {
21          	Scalar center = mean(contourMat);
22          	Point2i touchPoint(center[0], center[1]);
23          	touchPoints.push_back(touchPoint);
24      	}
25  	}

В последней части происходит отправка событий в систему. Координаты для событий нажатия на поверхность берутся из ранее созданного массива touchPoints.

1   	// Send TUIO cursors
2     	tuioTime = TuioTime::getSessionTime();
3     	tuio->initFrame(tuioTime);
4
5   	for (unsigned int i=0; i < touchPoints.size(); i++) { // touch points
6       	float cursorX = (touchPoints[i].x - xMin) / (xMax - xMin);
7         	float cursorY = 1 - (touchPoints[i].y - yMin) / (yMax - yMin);
8       	TuioCursor* cursor = tuio->getClosestTuioCursor(cursorX,cursorY);
9
10      	LOGD("Touch detected %d %d", (int)touchPoints[i].x, (int)touchPoints[i].y);
11
12      	// TODO improve tracking (don't move cursors away, that might be closer to another touch point)
13        	if (cursor == nullptr || cursor->getTuioTime() == tuioTime) {
14          	tuio->addTuioCursor(cursorX,cursorY);
15          	eventInjector->sendEventToTouchDevice((int)(touchPoints[i].x - xMin),
16                                                  	(int)(touchPoints[i].y - yMin));
17          	LOGD("TUIO cursor was added at %d %d", (int)touchPoints[i].x, (int)touchPoints[i].y);
18      	} else {
19          	tuio->updateTuioCursor(cursor, cursorX, cursorY);
20      	}
21  	}

Для отправки событий в систему вызывается функция sendEventToTouchDriver(), а для отправки сообщения TUIO серверу функции addTuioCursor() и updateTuioCursor().

В конце обсуждения кода хотелось бы рассказать о модуле отправки событий системе. Модуль называется stouchEventInjector.cpp. В самом начале работы в конструкторе с помощью функции open() открывается файл событий устройства ввода /dev/input/eventX, где X — это число. Модуль сам пытается найти дескриптор, связанный с нужным драйвером (sun4i_ts). Для этого последовательно вызывается функция getevent с ключем -pl для каждого существующего файла /dev/input/eventX. Отправка события, на самом деле, — это запись в файл /dev/input/eventX структуы uinput_event с помощью функции write(). У тачскрина имеется своя система координат с максимальным и минимальным значением по осям, в случае с sun4i-ts максимальное число по обеим осям ох и оу равно 4095. Последовательность команд, которую нужно отправить для симуляции нажатия на тачскрин можно найти в исходниках в функции sendTouchDownAbs().

Для автоматического запуска драйвера тачскрина после старта устройства, как я говорил в начале, нужно изменить конфигурацию сборки Android. Для сборки Android последнюю версию Ubuntu в моем случае версия была 14.10. Исходный код берем отсюда Cubieboard A10 Android и распаковываем. Нам необходимо изменить два файла:

android/device/softwinner/apollo-cubieboard/init.sun4i.rc
android/frameworks/base/data/etc/platform.xml

В файле init.sun4i.rc необходимо раскоментировать строку insmod /system/vendor/modules/sun4i-ts.ko. В файле platform.xml необходимо добавить группы usb, input и shell в секцию INTERNET:

		<group gid="usb"/>
		<group gid="input"/>
		<group gid="shell"/>

После внесения изменений запускаем сборку командой:

./build.sh -p sun4i_crane -k 3.0

Для сборки версии Android ICS необходим компилятор GCC версии 4.6 и make версии 3.81. Если версия компилятора и make отличается от необходимой, то ее можно изменить командами:

sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-4.6 60 --slave /usr/bin/g++ g++ /usr/bin/g++-4.6
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-4.9 40 --slave /usr/bin/g++ g++ /usr/bin/g++-4.9
sudo update-alternatives --config gcc
sudo mv /usr/bin/make /usr/bin/make40
sudo update-alternatives --install /usr/bin/make make /usr/local/bin/make 60
sudo update-alternatives --install /usr/bin/make make /usr/bin/make40 40
sudo update-alternatives --config make

Далее следуем инструкциям на странице Cubieboard A10 Android. В процессе сборки могут возникнуть ошибки компиляции. Подсказки для исправления ошибок можно найти в секции Fix building issues в файле fix_android_firmware.readme в репозитории с исходным кодом. Для подключения платы к ПК необходимо добавить правила для подключения устройства по USB для этого создаем файл:

 /etc/udev/rules.d/51-android.rules

И добавляем следующую строку:

SUBSYSTEM=="usb", ATTRS{idVendor}=="18d1", ATTRS{idProduct}=="0003",MODE="0666"

Чтобы изменения вступили в силу, перезапускаем сервис udev:

$sudo chmod a+rx /etc/udev/rules.d/51-android.rules
$sudo service udev restart

Подключаем плату к ПК и заливаем образ прошивки sun4i_crane_cubieboard.img с помощью утилиты LiveSuit. Перед установкой внимательно прочитайте инструкцию к LiveSuit, если установить неправильно, то приложение не сможет загрузить образ на устройство. После загрузки образа и перезапуска платы можно установить и запустить приложение SimpleTouch. Приложение автоматически запустит сервис, который захватывает/обрабатывает данные от Kinect и отправляет события системе. Приложение можно просто свернуть и запустить какую-нибудь игру из PlayMarket.

Исходный код можете скачать с bitbucket.

Видео демонстрации работы:

Комментарии (25)


  1. EminH
    20.04.2015 12:05

    Непонятно почему именно Cubieboard/Андроид? Что мешало поставит полноразмерную плату? пространства хватить должно


    1. sub_null Автор
      20.04.2015 12:33

      Скорее цена. Если перейти на x86, то понадобится дорогой БП, охлаждение. Кроме того Андроид дает большой выбор приложений из Play Market.


      1. Big_Lebowski
        20.04.2015 14:33

        На фоне цены проектора + сенсора кинект, затраты перехода на x86 довольно незначительны, как мне кажется


        1. sub_null Автор
          20.04.2015 15:12
          +1

          А какие преимущества перехода на x86, кроме производительности?


  1. ignat99
    20.04.2015 13:26

    Нету доступа к репозитарию bitbucket.org/rdv0011/stouch2. Где можно посмотреть исходный код?


    1. sub_null Автор
      20.04.2015 15:14

      Bitbucket требует регистрации. Если не пустит и после регистрации скажите я вам инвайт пришлю.


      1. ignat99
        20.04.2015 15:39

        У меня там есть регистрация (ignat99), не пускает всё равно.


        1. sub_null Автор
          20.04.2015 16:27
          +1

          Отправил инвайт. Вообще странно. Я расшарил репу с проектом. Bitbucket пишет, что для доступа нужна только регистрация.


          1. ignat99
            20.04.2015 17:15

            Спасибо, получил доступ, недавно смотрел Hi3518_SDK_V1.0.7.0 от китайской IP камеры и думал какой следующий шаг для камер наблюдения. Возможно ваш код как раз для будущих систем интерактивных камер. Хорошо, что опытным путём установлена производительность и стало ясно что A20 не достаточно для этого. До вашей статьи я думал иначе. Но вероятно, если сильно оптимизировать код под задачу, то, вероятно, A20 вполне хватит, правда будут дополнительные затраты по питанию на эти экстра функции.


            1. sub_null Автор
              20.04.2015 17:31

              Я использую cubieboard A10. На cubieboard A20 по идее должно быть значительно быстрее из-за того, что игра может «крутиться» на одном ядре, а приложения по распознанию на другом. В идеале, что бы получить 30FPS нужен ODROID-XU3 Lite. По поводу камер наблюдения есть интересный проект сенсор глубины на основе двух камер. Реализация на FPGA проект. В этой реализации можно получить до 120 кадров глубины в секунду. Если заменить Kinect на подобный сенсор, можно существенно увеличить отзывчивость.


  1. Artima
    20.04.2015 14:12
    +2

    А в связи с чем такая отзывчивость плохая?


    1. sub_null Автор
      20.04.2015 15:19

      Слабоват Cubieboard A10. Нужен Cubieboard A80, а еще лучше ODROID-XU3 Lite. На лэптопе x86 FPS равен максимальному для Kinet 30FPS, но это без запущенной игры.


  1. sub_null Автор
    20.04.2015 17:00

    Исходники можно скачать отсюда репозиторий


  1. sub_null Автор
    20.04.2015 17:04

    Ссылку на исходный код в статье поменял на открытый репозиторий.


  1. zm33y
    21.04.2015 08:59

    Крутой папа! А какова точность позиционирования? На видео видно, что дочка наступает на таракана, но тот не умирает — обидно же.


    1. sub_null Автор
      05.05.2015 16:41

      Мощности A10 не хватает. Более мощную систему не пробовал, но должно хватить A80 или ODROID-C1. На самом деле и Kinect не очень быстрый; 30FPS маловато для подобных задач.


  1. EminH
    21.04.2015 14:09

    на х86 можно попробовать без кинекта, с обычной webcam с помощью viacam , например
    только калибровка или даже лучше маркер понадобится- например красные носочки


    1. Big_Lebowski
      21.04.2015 18:14

      а что, виакам умеет строить карту глубины?


      1. EminH
        21.04.2015 19:18

        Глубина не нужна, «клики» будут по замедлению движения


        1. Big_Lebowski
          21.04.2015 22:47

          звучит не слишком удобно :)


          1. EminH
            21.04.2015 23:01

            Надо проверить, то что на видео тоже неидеально


            1. Big_Lebowski
              21.04.2015 23:23

              ну, попробуйте модифицировать драйвер мыши, чтобы он кликал, когда курсор останавливается — думаю это довольно честное моделирование.


              1. EminH
                22.04.2015 18:24

                Проблема с драйвером мыши в том что курсор один, а ног у большинства людей две
                может моделировать как две мышки?


                1. sub_null Автор
                  05.05.2015 16:34

                  А как несколько мышек будут выглядеть для приложения?


                  1. EminH
                    05.05.2015 17:42

                    без понятия, надо экспериментировать