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


Осенью прошлого года было представлено достаточно интересное гиковское устройство, совмещающее в себе проектор и сенсорный экран. Маркетологи позиционировали его как устройство в каждый дом, но мы-то с вами знаем, что это чисто гиковская игрушка. Я был в восторге от первых фотографий (фейковых, кстати).

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

Цель

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

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

Девайс состоит из трех частей:

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

Проектор

У меня есть нормальный FullHD проектор, однако он большой, тяжелый и дорогой. Снимать его со стойки, ставить на неустойчивый штатив для экспериментов очень не хотелось.

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

+2 900 рублей

Камера

Обычную камеру использовать нельзя. Постоянно меняющееся изображение не позволит сколь-либо уверенно детектировать руку пользователя.

Вариантов два:

1) Переделать обычную камеру в ИК камеру
2) Взять готовую камеру глубины

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

Кстати, теплак в комплексе с камерой глубины сводит все артефакты детектирования практически к нулю. Но это ОЧЕНЬ дорого. Хм… Может поэтому оригинальное устройство стоит 150 000?

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

Хм… Может в оригинальном устройстве тоже внутри айфон спрятан? Это тоже всё объясняет.
Мне кинект обошелся в 7600 рублей + адаптер для ПК — в 2700.

+10 300 рублей

Компьютер

Т.к. мы делаем портативный девайс, то под него пришлось прикупить ноут. Есть мнение, что кинект очень требователен к железу. Это ошибочное мнение. Кинект — это просто камера. Зато очень требователен к железу софт, который анализирует данные с камеры.

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

+2 400 рублей

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

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

Итого

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

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

Нам понадобится 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

Фон (глубина):

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

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

Благодаря openni изображение с камеры глубины и с обычной камеры геометрически совпадают.

Значит, если мы сможем определить на изображении с обычной камеры где находится экран — сможем определить и где экран на карте глубины.

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

На выходе получаем двухцветное изображение:

Теперь в дело вступает 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 точки прямоугольника соответствующего экрана. Как преобразовать координаты внутри одной системы в другую?

Я не буду пересказывать алгоритм, его вы можете найти здесь.

Единственное, на что хочу обратить внимание — формулы содержат ошибки/опечатки. Их легко обнаружить, если чуть вникнуть в суть формулы. В целом, алгоритм вполне себе рабочий и простой: координата считается как расстояние до одной грани, деленное на сумму расстояний до противоположных граней. Очевидно. Просто. И работает!

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

То, ради чего всё затевалось и к чему всё готовилось. Начинаем мы с получения карты глубины за вычетом фона. Берем кадр с камеры глубины, проходимся по всем пикселям, которые находятся в прямоугольнике, описанном вокруг четырехугольника экрана.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) Ограничиваем минимальное перемещение.

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

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

[embedded content]

Критика

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

В оригинальном устройстве положение камеры и датчика касаний не настраиваемо. Если я правильно понимаю ситуацию, попытка поставить девайс повыше (на подставку) приведет к неработоспособности тач-сенсора!

Но я такого сделать не могу, т.к. у меня нет проектора с коротким фокусным расстоянием и оптикой, позволяющей создавать квадратное изображение при остром угле к поверхности. А городить огромную бандуру, у которой сверху будет висеть проектор, а снизу стоять кинект мне очень не хочется.

Выводы

Мне бы очень хотелось покрутить и раскурочить оригинальное устройство, чтобы понять, что же в нём столько стоит.

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

P.S.

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