Sanya Nischebrod Touch — потрогать самодельное будущее

awxmg9mwvyjescylgfzfdcx4ej4.png
Осенью прошлого года было представлено достаточно интересное гиковское устройство, совмещающее в себе проектор и сенсорный экран.
Маркетологи позиционировали его как устройство в каждый дом, но мы-то с вами знаем, что это чисто гиковская игрушка.
Я был в восторге от первых фотографий (фейковых, кстати).
Но когда была озвучена цена в 150 000 рублей за устройство — как и положено нищеброду, не смог удержаться и высказался в духе «За что, чёрт возьми! Это же очень простое устройство!».
Технически устройство и правда не выглядит сложным — как и многие гики, proof of concept сенсорного проектора я делал много лет назад и считаю это достаточно простой штукой. Однако, и в личку, и в комментариях к той статье меня попросили чуть подробнее описать процесс сборки. Так и родился проект Sanya N Touch.

Статья об оригинальном устройстве вышла на ГТ в конце ноября. Как только стало понятно, что надо делать свой проект — заказал проектор. И он очень удачно пришел на новогодние праздники, так что мне было чем заняться в первую неделю января. Основная причина задержки — ожидание заказанного под задачу проектора.

Цель


Если Вы — такой же гик, как и я, то вам очень хочется поиграться с тачскрином на проецируемом изображении.
Но платить 3000$ за игрушку, которая увлечет максимум на час вряд ли захочется.
Цель — создать дешевую альтернативу.
Понятно, что воткнуть всё в компактный корпус не получится. Конкурировать на этом поле с фабричным производством бессмысленно. В остальном же — раздолье. :)

Оборудование


Девайс состоит из трех частей:
1) Проектор
2) Камера
3) Компьютер, чтобы всё это считать.

Проектор


2wdtlqggmmvroylvncghkk8efii.jpeg
У меня есть нормальный FullHD проектор, однако он большой, тяжелый и дорогой. Снимать его со стойки, ставить на неустойчивый штатив для экспериментов очень не хотелось.
Да и, как мы знаем, в оригинальном устройстве FullHD — это фикция. Так что не будем отставать.
Специально под Sanya N Touch я нашел проектор GM 60 в Китае за 2900 рублей с курьерской доставкой до дома. Кстати, в магазине ledunix год назад такой проектор со скидкой продавался всего лишь за 7000 рублей! 640×480 — идеальное разрешение, однозначно берем.

+2900 рублей

Камера


sil-zd3rbd7dwz4itqlki7mst24.jpeg
Обычную камеру использовать нельзя. Постоянно меняющееся изображение не позволит сколь-либо уверенно детектировать руку пользователя.
Вариантов два:
1) Переделать обычную камеру в ИК камеру
2) Взять готовую камеру глубины
Есть, конечно, и другие экзотические варианты. Например, можно использовать тепловизор для гарантированного детекта конечностей пользователя.
Кстати, теплак в комплексе с камерой глубины сводит все артефакты детектирования практически к нулю. Но это ОЧЕНЬ дорого. Хм… Может поэтому оригинальное устройство стоит 150 000?
Не будем так сильно заморачиваться. Возьмем кинект. Тем более что у меня он уже есть и покупать ничего не надо.
К сожалению, кинект сняли с производства, из-за чего цена на оставшиеся комплекты резко выросла. Но можно примастрячить и новый iPhone, в него как раз добавили камеру глубины, аналогичную кинекту.
Хм… Может в оригинальном устройстве тоже внутри айфон спрятан? Это тоже всё объясняет.
Мне кинект обошелся в 7600 рублей + адаптер для ПК — в 2700.

+10300 рублей

Компьютер


r4vryq3urswfwjxh1dxypstal5q.jpeg
Т.к. мы делаем портативный девайс, то под него пришлось прикупить ноут.
Есть мнение, что кинект очень требователен к железу. Это ошибочное мнение.
Кинект — это просто камера. Зато очень требователен к железу софт, который анализирует данные с камеры.
Мы же будем работать напрямую с изображением. Хотя, конечно, обработку всё равно придется делать.
Ноут я купил Asus X55SV за 2000 рублей. Всё с ним хорошо, но знающие люди уже посмеиваются.
Кинект требует USB 3.0 и на USB 2 не заработает нормально, а на этом ноуте USB 3 отсутствует как класс. Поэтому пришлось прикупить PCMCIA карточку расширения для ноута с USB 3.0 портами. Это, мягко говоря, лотерея — кинект весьма придирчив к USB и даже не со всеми честными USB 3 работает.
Карточка обошлась в 400 рублей.

+2400 рублей

Всякая всячина


600 рублей — штатив
Обрезки алюминиевого профиля и старая фанера — бесценнобесплатно
Работу по изготовлению скворечника считать не будем:
kxfemn405g59iq-ycl68riod-ho.jpeg

Итого


16200 рублей — бюджет данного проекта

Программное обеспечение


Нам понадобится libfreenect2, openni и opencv.
libfreenect2 отвечает за низкоуровневую работу с kinect.
openni — универсальная библиотека верхнего уровня, через которую общаемся с libfreenect.
Если вы будете использовать первый кинект или, например, asus xtion — изменится только установленный драйвер, а весь код для openni останется прежним.
opencv используем для обработки изображения, полученного с камеры.

Общий алгоритм работы


Инициализация:
1) Совмещаем спроецированный экран и изображение, полученное с камеры.
2) В течении нескольких секунд сохраняем текущее состояние камеры глубины в буфере. Несколько секунд нужны, чтобы накопилась ошибка и в дальнейшем меньше влияла.
Работа:
3) Постоянно забираем карту глубины, с помощью opencv находим руку и крайнюю точку руки.
4) Используем найденную точку в качестве указателя мыши.

Детали реализации

Инициализация контекста

if (openni::OpenNI::initialize()==openni::STATUS_OK)
{
  if (m_Device.open(openni::ANY_DEVICE)==openni::STATUS_OK){
    m_Device.setImageRegistrationMode(openni::IMAGE_REGISTRATION_DEPTH_TO_COLOR);
    if (m_DepthStream.stream.create(m_Device, openni::SENSOR_DEPTH) == openni::STATUS_OK){
      if (m_DepthStream.stream.start() == openni::STATUS_OK){
        if (m_ColorStream.stream.create(m_Device, openni::SENSOR_COLOR) == openni::STATUS_OK){
          if (m_ColorStream.stream.start() == openni::STATUS_OK){


openni: OpenNI: initialize () — запускаем openni
m_Device.open (openni: ANY_DEVICE) — открываем первую попавшуюся камеру. Хорошо работает, если камера одна. Если их несколько, то лучше указывать — какую конкретно хотим открыть.
m_Device.setImageRegistrationMode (openni: IMAGE_REGISTRATION_DEPTH_TO_COLOR) — просим openi взять на себя работу по совмещению изображений с камер.
Дело в том, что камер у того же кинекта две. Одна — обычная и одна — камера глубины. И они физически находятся в разных местах. Соответственно, изображение, получаемое от них, различается. И различается оно очень сильно, т.к. у них еще и характеристики разные. К счастью, openni умеет с этим бороться, выдавая совмещенные друг с другом стримы.
Остальные строки простые — создаем стрим, запускаем стрим.

Определение фона


Заливаем экран проектора черным цветом. И в течении нескольких секунд читаем данные с двух стримов.
В карту фонового цвета записываем самые яркие значения, в карту фоновой глубины — самые ближние к камере.
Яркость получаем из RGB по формуле: 0.2125*RED+ 0.7150*GREEN + 0.0722*BLUE

Фон (глубина):
-jqbq6nhgxmhtgoa5gegab83_m8.png

Фон (яркость):
xwzc6y2nw5hqlmrjorpuwtfgdgs.png

Определение позиции экрана (синхронизация с камерой)


Благодаря openni изображение с камеры глубины и с обычной камеры геометрически совпадают.
Значит, если мы сможем определить на изображении с обычной камеры где находится экран — сможем определить и где экран на карте глубины.
Показываем через проектор белый экран, считываем его и заполняем буфер по следующему алгоритму:
Если пиксель ярче, чем фоновый — заливаем белым, если темнее — черным.
На выходе получаем двухцветное изображение:
r1ac-4ywkwheq3ywcc0m5tkvrxk.png

Теперь в дело вступает opencv.
Создаем cv: Mat (матрица, двумерный массив, основной тип в opencv для хранения изображений и других двумерных данных), передав в качестве изображения туда наш двухцветный снимок:

cv::Mat img(m_ColorStream.resolution.height(), m_ColorStream.resolution.width(), CV_8UC1, m_ColorNoiseMap, m_ColorStream.resolution.width());


и просим opencv найти все контуры на изображении:

cv::findContours( img, contours0, hierarchy, cv::RETR_TREE, cv::CHAIN_APPROX_SIMPLE);

Естественно, он найдет не только экран, но и мусорные контуры, которые появились на снимке.
Однако, вполне очевидно, что наш контур тот, что обладает самым большим периметром. Так что перебираем все контуры и работаем только с самым большим:

int screenContourIndex = 0;
    double maxPerimeter = cv::arcLength(contours0[0],true);
    for (int i = 1; i

Полученный контур соответствует геометрии нашего экрана. Но нам нужно четыре угловых точки, а полученный через opencv контур, кроме нужных нам точек, может содержать еще множество промежуточных точек — если по каким-то причинам четырехугольник на изображении имел не идеально ровные грани.

Как определить углы?
В частном случае есть очень простой способ: вписать контур в прямоугольник, в котором ближайшая к каждому из углов прямоугольника точка контура и будет соответствующей крайней точкой экрана.
Вписать очень просто — само изображение с камеры уже и есть тот самый прямоугольник, в который вписан контур… С большой погрешностью, конечно, вписан… Но это не имеет значения, алгоритм всё равно будет работать в частном случае.
Теперь поговорим о достижении этого частного случая: нам нужно, чтобы спроецированное изображение не было повернуто относительно камеры.
Как этого достичь? Расположить проектор и камеру друг над другом. Тут нам и пришлось прикручивать всё на скворечник, сделанный ранее. Скворечник обеспечивает сонаправленность проектора и камеры, что снимает множество вопросов по определению геометрии экрана.

Формируем коэффициенты для линейного преобразования


У нас есть 4 точки четырехугольной проекции изображения, полученной на снимке с камеры.
У нас есть 4 точки прямоугольника соответствующего экрана.
Как преобразовать координаты внутри одной системы в другую?
Я не буду пересказывать алгоритм, его вы можете найти здесь:
math.stackexchange.com/a/104595/517524
Единственное, на что хочу обратить внимание — формулы содержат ошибки/опечатки. Их легко обнаружить, если чуть вникнуть в суть формулы. В целом, алгоритм вполне себе рабочий и простой:
координата считается как расстояние до одной грани, деленное на сумму расстояний до противоположных граней. Очевидно. Просто. И работает!

Детект указателя


То, ради чего всё затевалось и к чему всё готовилось.
Начинаем мы с получения карты глубины за вычетом фона.
Берем кадр с камеры глубины, проходимся по всем пикселям, которые находятся в прямоугольнике, описанном вокруг четырехугольника экрана.

uint16_t diff = backgroundValue - currentValue;
if (diff>HAND_TRACKING_MIN_DIFF){
  if (diff>HAND_TRACKING_MAX_DIFF)
    diff = 0;
  if (diff>255)
    diff = 255;  
}
else
  diff = 0;
*testMap = diff;


Для каждой точки, если расстояние в точке меньше фонового на HAND_TRACKING_MIN_DIFF и меньше HAND_TRACKING_MAX_DIFF, записываем это значение в восьмибитный массив. Чтобы не выйти за пределы массива, всё что больше 255 записываем как 255.
Нижняя граница позволяет убрать шум с камеры и ложные срабатывания из-за вибраций, когда камера чуть-чуть приближается/удаляется от рабочей поверхности.
Верхняя граница позволяет убрать «битые» пиксели с карты глубины. «Битые» пиксели появляются на гранях, которые близки к перпендикуляру к камере.

После этой операции мы получаем карту глубины, на которой есть ярко выраженная рука. И много постороннего шума.
Практически все остальные шаги нужны для того, чтобы убрать шумы.
Первым делом проходимся блюром. Это уберет мелкие точки с радара:

cv::GaussianBlur(hMap, img, cv::Size(5, 5),0);

Затем превращаем полученное изображение снова в двухцветное без градиентов:

cv::threshold(img,img,10,255,cv::THRESH_TOZERO);


Данный метод всё, что от 10 до 255 превратит в 255, а всё, что меньше 10 — в ноль.

Находим контуры.

cv::findContours( img, contours0, hierarchy, cv::RETR_TREE, cv::CHAIN_APPROX_SIMPLE);


И рисуем их обратно, но уже с заполнением. Это опять же уберет всякие левые шумы с картинки.

for (int c = 0; c


И снова находим контуры. О_О

cv::findContours( img, contours0, hierarchy, cv::RETR_TREE, cv::CHAIN_APPROX_SIMPLE);


Зачем?
Первый поиск может давать несколько сотен мелких контуров на одну руку! То есть алгоритм не может уверенно понять, что рука — это один контур и дает на выходе множество мелких контуров.
Рисуя их с заливкой, мы получаем стабильное изображение с рукой без разрывов. И следующий поиск даст нам один чистый контур!
Можно попробовать самостоятельно объединить контуры, но мне не хотелось возиться.

Иногда opencv решает, что края экрана — это тоже контур. Иногда запихивает углы экрана в отдельный контур, иногда — в контур с рукой. Находим все точки, которые касаются экрана и безжалостно их удаляем. Может так случиться, что потеряется кусок руки с края экрана, но это ни на что не влияет.
Если после всех манипуляций у нас еще есть валидные контуры, то находим среди них самый большой, чтобы отсечь остатки шума — если он еще есть. Код приводить не буду — он идентичен тому, что был выше с поиском самого большого контура.

Окей. После всех фильтраций у нас есть контур руки пользователя. И? Чего с ним делать-то? Нам же нужно взять одну точку и решить, что она будет указателем. Причем точка должна уверенно определяться примерно в одном и том же месте при перемещении и изменении формы руки…
И как же, чёрт возьми, это сделать?
Способ есть. Надо перестать воспринимать руку как сложный объект и принять во внимание, что это указатель. Стрела. С осью и точкой на конце.
Для начала найдем ось.
Для этого есть отличный инструмент:

cv::fitLine(contours0[maxIndex],line,CV_DIST_L2,0,0.01,0.01);


fitLine найдет нам линию, лучше всего подходящую в качестве оси контура.
Линия задана некой точкой и вектором направления единичной длины.
Координаты точки находятся внутри контура и больше нам о ней ничего не известно.
Поэтому мы выходим в двух направления из этой точки, пока не дойдем до границы контура.
Как только нашли — у нас есть ось. Осталось решить — какая из точек будет нашим указателем.
Решение очень простое — какая ниже, та и указатель.

Подавление дрожания и пропадания указателя


Указатель, полученный нами, достаточно хорошо работает.
Однако, он дергается как умалишенный из-за шума карты глубины и сбоев в работе алгоритмов определения контура.
Дергается не то чтобы сильно, но раздражающе.
Применяем два стандартных метода подавления:
1) Ограничиваем скорость перемещения
2) Ограничиваем минимальное перемещение.

К сожалению, ограничение скорости приводит к тому, что быстро перемещать указатель становится невозможным. Плюс курсор визуально отстает от движения.
Ограничение минимального перемещения мешает совершать точные короткие движения.
Но в целом результат вполне приемлем!

Критика


Вы можете заметить, что у меня на видео очень высоко поднята рука. При кинекте, находящемся настолько высоко, определить касание поверхности практически невозможно. Если расположить кинект так, чтобы он смотрел вдоль стола, то детект касания упростится в несколько раз и станет гораздо более точным. Но! Тогда камера кинекта не сможет использоваться для определения проекционного экрана!
В оригинальном устройстве положение камеры и датчика касаний не настраиваемо. Если я правильно понимаю ситуацию, попытка поставить девайс повыше (на подставку) приведет к неработоспособности тач-сенсора!
Но я такого сделать не могу, т.к. у меня нет проектора с коротким фокусным расстоянием и оптикой, позволяющей создавать квадратное изображение при остром угле к поверхности. А городить огромную бандуру, у которой сверху будет висеть проектор, а снизу стоять кинект мне очень не хочется.

Выводы


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

P.S.


Кстати, если использовать 3D DLP проектор + датчик положения очков, наш тач-стол можно превратить в 3Д тач-стол. Но, к сожалению, только для одного наблюдателя.

© Geektimes